snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,446 @@
import type { IRoomNativeFederated, IUser } from '@rocket.chat/core-typings';
import { Visibility } from 'matrix-js-sdk';
import type {} from '../../../../../apps/meteor/app/api/server/v1/rooms.ts';
import { api } from '../../../../../apps/meteor/tests/data/api-data';
import { sendMessage } from '../../../../../apps/meteor/tests/data/messages.helper';
import { createRoom, acceptRoomInvite, getRoomMembers } from '../../../../../apps/meteor/tests/data/rooms.helper';
import { type IRequestConfig, getRequestConfig, createUser } from '../../../../../apps/meteor/tests/data/users.helper';
import { IS_EE } from '../../../../../apps/meteor/tests/e2e/config/constants';
import { retry } from '../../../../../apps/meteor/tests/end-to-end/api/helpers/retry';
import { federationConfig } from '../helper/config';
import { SynapseClient } from '../helper/synapse-client';
(IS_EE ? describe : describe.skip)('Federation Ban/Unban', () => {
let rc1AdminRequestConfig: IRequestConfig;
let rc1User1RequestConfig: IRequestConfig;
let hs1AdminApp: SynapseClient;
beforeAll(async () => {
rc1AdminRequestConfig = await getRequestConfig(
federationConfig.rc1.url,
federationConfig.rc1.adminUser,
federationConfig.rc1.adminPassword,
);
await createUser(
{
username: federationConfig.rc1.additionalUser1.username,
password: federationConfig.rc1.additionalUser1.password,
email: `${federationConfig.rc1.additionalUser1.username}@rocket.chat`,
name: federationConfig.rc1.additionalUser1.username,
},
rc1AdminRequestConfig,
);
rc1User1RequestConfig = await getRequestConfig(
federationConfig.rc1.url,
federationConfig.rc1.additionalUser1.username,
federationConfig.rc1.additionalUser1.password,
);
hs1AdminApp = new SynapseClient(federationConfig.hs1.url, federationConfig.hs1.adminUser, federationConfig.hs1.adminPassword);
await hs1AdminApp.initialize();
});
afterAll(async () => {
if (hs1AdminApp) {
await hs1AdminApp.close();
}
});
describe('Ban from RC side', () => {
let channelName: string;
let federatedChannelId: string;
beforeAll(async () => {
channelName = `fed-ban-rc-${Date.now()}`;
const createResponse = await createRoom({
type: 'p',
name: channelName,
members: [federationConfig.hs1.adminMatrixUserId, federationConfig.rc1.additionalUser1.username],
extraData: { federated: true },
config: rc1AdminRequestConfig,
});
federatedChannelId = createResponse.body.group._id;
// Accept invitation on Synapse side
await hs1AdminApp.acceptInvitationForRoomName(channelName);
// Accept invitation for the local RC user
await acceptRoomInvite(federatedChannelId, rc1User1RequestConfig);
// Wait for Synapse to see the RC user
await retry(
'wait for RC user on Synapse',
async () => {
const member = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.additionalUser1.matrixUserId);
expect(member).not.toBeNull();
expect(member!.membership).toBe('join');
},
{ retries: 10, delayMs: 2000 },
);
}, 30000);
it('should ban the RC user via rooms.banUser', async () => {
const response = await rc1AdminRequestConfig.request.post(api('rooms.banUser')).set(rc1AdminRequestConfig.credentials).send({
roomId: federatedChannelId,
userId: rc1User1RequestConfig.credentials['X-User-Id'],
});
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('success', true);
});
it('should list the banned user via rooms.bannedUsers', async () => {
const response = await rc1AdminRequestConfig.request
.get(api('rooms.bannedUsers'))
.set(rc1AdminRequestConfig.credentials)
.query({ roomId: federatedChannelId });
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('success', true);
expect(response.body.bannedUsers).toEqual(
expect.arrayContaining([expect.objectContaining({ _id: rc1User1RequestConfig.credentials['X-User-Id'] })]),
);
});
it('should reflect ban on Synapse side', async () => {
await retry(
'wait for ban on Synapse',
async () => {
const member = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.additionalUser1.matrixUserId);
expect(member).not.toBeNull();
expect(member!.membership).toBe('ban');
},
{ retries: 10, delayMs: 2000 },
);
});
it('should not list banned user in room members', async () => {
const membersResponse = await getRoomMembers(federatedChannelId, rc1AdminRequestConfig);
const banned = membersResponse.members.find((m: IUser) => m._id === rc1User1RequestConfig.credentials['X-User-Id']);
expect(banned).toBeUndefined();
});
it('should unban the user via rooms.unbanUser', async () => {
const response = await rc1AdminRequestConfig.request.post(api('rooms.unbanUser')).set(rc1AdminRequestConfig.credentials).send({
roomId: federatedChannelId,
userId: rc1User1RequestConfig.credentials['X-User-Id'],
});
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('success', true);
});
it('should no longer list the user as banned', async () => {
const response = await rc1AdminRequestConfig.request
.get(api('rooms.bannedUsers'))
.set(rc1AdminRequestConfig.credentials)
.query({ roomId: federatedChannelId });
expect(response.status).toBe(200);
const bannedIds = (response.body.bannedUsers || []).map((u: any) => u._id);
expect(bannedIds).not.toContain(rc1User1RequestConfig.credentials['X-User-Id']);
});
it('should reflect unban on Synapse side', async () => {
await retry(
'wait for unban on Synapse',
async () => {
const member = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.additionalUser1.matrixUserId);
expect(member).not.toBeNull();
expect(member!.membership).not.toBe('ban');
},
{ retries: 10, delayMs: 2000 },
);
});
});
describe('Ban and unban invited user (invite status preservation)', () => {
let channelName: string;
let federatedChannelId: string;
beforeAll(async () => {
channelName = `fed-ban-invite-${Date.now()}`;
const createResponse = await createRoom({
type: 'p',
name: channelName,
members: [federationConfig.hs1.adminMatrixUserId, federationConfig.rc1.additionalUser1.username],
extraData: { federated: true },
config: rc1AdminRequestConfig,
});
federatedChannelId = createResponse.body.group._id;
// Accept invitation on Synapse side only — RC user stays as INVITED
await hs1AdminApp.acceptInvitationForRoomName(channelName);
}, 30000);
it('should ban the invited user before they accept', async () => {
const response = await rc1AdminRequestConfig.request.post(api('rooms.banUser')).set(rc1AdminRequestConfig.credentials).send({
roomId: federatedChannelId,
userId: rc1User1RequestConfig.credentials['X-User-Id'],
});
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('success', true);
});
it('should have the user listed as banned', async () => {
const response = await rc1AdminRequestConfig.request
.get(api('rooms.bannedUsers'))
.set(rc1AdminRequestConfig.credentials)
.query({ roomId: federatedChannelId });
expect(response.status).toBe(200);
expect(response.body.bannedUsers).toEqual(
expect.arrayContaining([expect.objectContaining({ _id: rc1User1RequestConfig.credentials['X-User-Id'] })]),
);
});
it('should unban the user (removing the subscription entirely)', async () => {
const response = await rc1AdminRequestConfig.request.post(api('rooms.unbanUser')).set(rc1AdminRequestConfig.credentials).send({
roomId: federatedChannelId,
userId: rc1User1RequestConfig.credentials['X-User-Id'],
});
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('success', true);
});
it('should have no subscription after unban (user must be re-invited)', async () => {
const response = await rc1User1RequestConfig.request
.get(api('subscriptions.getOne'))
.set(rc1User1RequestConfig.credentials)
.query({ roomId: federatedChannelId });
expect(response.body.subscription).toBeNull();
});
});
describe('Ban from Synapse side', () => {
let channelName: string;
let federatedChannelId: string;
let synapseRoomId: string;
beforeAll(async () => {
channelName = `fed-ban-synapse-${Date.now()}`;
synapseRoomId = await hs1AdminApp.createRoom(channelName, Visibility.Private);
await hs1AdminApp.inviteUserToRoom(synapseRoomId, federationConfig.rc1.additionalUser1.matrixUserId);
await hs1AdminApp.inviteUserToRoom(synapseRoomId, federationConfig.rc1.adminMatrixUserId);
const roomsResponse = await rc1AdminRequestConfig.request.get(api('rooms.get')).set(rc1AdminRequestConfig.credentials).expect(200);
expect(roomsResponse.body).toHaveProperty('success', true);
expect(roomsResponse.body).toHaveProperty('update');
const rcRoom = roomsResponse.body.update.find(
(room: IRoomNativeFederated) => room.federation?.mrid === synapseRoomId,
) as IRoomNativeFederated | null;
expect(rcRoom).not.toBeNull();
federatedChannelId = rcRoom!._id;
// Accept invitation for the local RC user
await acceptRoomInvite(federatedChannelId, rc1User1RequestConfig);
await acceptRoomInvite(federatedChannelId, rc1AdminRequestConfig);
// Wait for Synapse to see the RC user
await retry(
'wait for RC user on Synapse',
async () => {
const member = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.additionalUser1.matrixUserId);
expect(member).not.toBeNull();
expect(member!.membership).toBe('join');
},
{ retries: 10, delayMs: 2000 },
);
}, 30000);
it('should ban the RC user from Synapse', async () => {
await hs1AdminApp.banUser(synapseRoomId, federationConfig.rc1.additionalUser1.matrixUserId, 'federation ban test');
});
it('should reflect ban on RC side', async () => {
await retry(
'wait for ban to propagate to RC',
async () => {
const response = await rc1AdminRequestConfig.request
.get(api('rooms.bannedUsers'))
.set(rc1AdminRequestConfig.credentials)
.query({ roomId: federatedChannelId });
expect(response.status).toBe(200);
expect(response.body.bannedUsers).toEqual(
expect.arrayContaining([expect.objectContaining({ _id: rc1User1RequestConfig.credentials['X-User-Id'] })]),
);
},
{ retries: 10, delayMs: 2000 },
);
});
it('should not list banned user in room members on RC side', async () => {
const membersResponse = await getRoomMembers(federatedChannelId, rc1AdminRequestConfig);
const banned = membersResponse.members.find((m: IUser) => m._id === rc1User1RequestConfig.credentials['X-User-Id']);
expect(banned).toBeUndefined();
});
it('should unban the RC user from Synapse', async () => {
await hs1AdminApp.unbanUser(synapseRoomId, federationConfig.rc1.additionalUser1.matrixUserId);
});
it('should reflect unban on RC side', async () => {
await retry(
'wait for unban to propagate to RC',
async () => {
const response = await rc1AdminRequestConfig.request
.get(api('rooms.bannedUsers'))
.set(rc1AdminRequestConfig.credentials)
.query({ roomId: federatedChannelId });
expect(response.status).toBe(200);
const bannedIds = (response.body.bannedUsers || []).map((u: any) => u._id);
expect(bannedIds).not.toContain(rc1User1RequestConfig.credentials['X-User-Id']);
},
{ retries: 10, delayMs: 2000 },
);
});
});
describe('Ban from Synapse, re-invite and messaging after unban', () => {
let channelName: string;
let federatedChannelId: string;
let synapseRoomId: string;
it('should create room on Synapse and have RC user accept invite', async () => {
channelName = `fed-ban-reinvite-${Date.now()}`;
synapseRoomId = await hs1AdminApp.createRoom(channelName, Visibility.Private);
await hs1AdminApp.inviteUserToRoom(synapseRoomId, federationConfig.rc1.additionalUser1.matrixUserId);
// Wait for the invite to reach RC and find the room
await retry(
'wait for invite to reach RC',
async () => {
const roomsResponse = await rc1User1RequestConfig.request
.get(api('rooms.get'))
.set(rc1User1RequestConfig.credentials)
.expect(200);
const rcRoom = roomsResponse.body.update.find(
(room: IRoomNativeFederated) => room.federation?.mrid === synapseRoomId,
) as IRoomNativeFederated | null;
expect(rcRoom).not.toBeNull();
federatedChannelId = rcRoom!._id;
},
{ retries: 5, delayMs: 1000 },
);
await acceptRoomInvite(federatedChannelId, rc1User1RequestConfig);
// Wait for Synapse to see the RC user as joined
await retry(
'wait for RC user join on Synapse',
async () => {
const member = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.additionalUser1.matrixUserId);
expect(member).not.toBeNull();
expect(member!.membership).toBe('join');
},
{ retries: 5, delayMs: 1000 },
);
}, 30000);
it('should ban the RC user from Synapse', async () => {
await hs1AdminApp.banUser(synapseRoomId, federationConfig.rc1.additionalUser1.matrixUserId, 'ban before re-invite test');
// Wait for ban to propagate to RC — the banned user should no longer see the room
await retry(
'wait for ban on RC',
async () => {
const roomsResponse = await rc1User1RequestConfig.request
.get(api('rooms.get'))
.set(rc1User1RequestConfig.credentials)
.expect(200);
const rcRoom = roomsResponse.body.update.find((room: IRoomNativeFederated) => room.federation?.mrid === synapseRoomId);
expect(rcRoom).toBeUndefined();
},
{ retries: 5, delayMs: 1000 },
);
});
it('should send a message from Synapse while RC user is banned', async () => {
const messageText = `message-while-banned-${Date.now()}`;
await hs1AdminApp.sendTextMessage(channelName, messageText);
const synapseMessage = await hs1AdminApp.findMessageInRoom(channelName, messageText);
expect(synapseMessage?.content.body).toBe(messageText);
});
it('should unban the RC user from Synapse', async () => {
await hs1AdminApp.unbanUser(synapseRoomId, federationConfig.rc1.additionalUser1.matrixUserId);
// Verify unban on Synapse side — membership should no longer be 'ban'
await retry(
'wait for unban on Synapse',
async () => {
const member = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.additionalUser1.matrixUserId);
expect(member).not.toBeNull();
expect(member!.membership).not.toBe('ban');
},
{ retries: 5, delayMs: 1000 },
);
});
it('should re-invite the RC user from Synapse and have them accept', async () => {
await hs1AdminApp.inviteUserToRoom(synapseRoomId, federationConfig.rc1.additionalUser1.matrixUserId);
// Wait for the re-invite to reach RC
await retry(
'wait for re-invite to reach RC',
async () => {
const response = await rc1User1RequestConfig.request
.get(api('subscriptions.getOne'))
.set(rc1User1RequestConfig.credentials)
.query({ roomId: federatedChannelId });
expect(response.body.subscription).not.toBeNull();
},
{ retries: 5, delayMs: 1000 },
);
await acceptRoomInvite(federatedChannelId, rc1User1RequestConfig);
// Wait for Synapse to see the RC user as joined again
await retry(
'wait for RC user re-join on Synapse',
async () => {
const member = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.additionalUser1.matrixUserId);
expect(member).not.toBeNull();
expect(member!.membership).toBe('join');
},
{ retries: 5, delayMs: 1000 },
);
}, 30000);
it('should allow the RC user to send a message after re-joining', async () => {
const messageText = `message-after-rejoin-${Date.now()}`;
const sendResponse = await sendMessage({ rid: federatedChannelId, msg: messageText, config: rc1User1RequestConfig });
expect(sendResponse.body).toHaveProperty('success', true);
// Validate message is received on Synapse
const synapseMessage = await hs1AdminApp.findMessageInRoom(channelName, messageText);
expect(synapseMessage).not.toBeNull();
expect(synapseMessage?.content.body).toBe(messageText);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,190 @@
import type { IRoomNativeFederated, IUser } from '@rocket.chat/core-typings';
import { Visibility } from 'matrix-js-sdk';
import { api } from '../../../../../apps/meteor/tests/data/api-data';
import { acceptRoomInvite } from '../../../../../apps/meteor/tests/data/rooms.helper';
import {
type IRequestConfig,
createUser,
deleteUser,
getRequestConfig,
getUserByUsername,
} from '../../../../../apps/meteor/tests/data/users.helper';
import { IS_EE } from '../../../../../apps/meteor/tests/e2e/config/constants';
import { retry } from '../../../../../apps/meteor/tests/end-to-end/api/helpers/retry';
import { federationConfig } from '../helper/config';
import { SynapseClient } from '../helper/synapse-client';
const remoteUser = federationConfig.hs1.firstContactUser;
const localUser = federationConfig.rc1.firstContactUser;
/**
* Every other federation spec reuses Synapse users that earlier specs have already pulled into
* the local database, so the membership handlers always find a complete user document and the
* creation path is never observed. This spec reserves a Synapse user nobody else touches, removes
* the local document if a previous run left one behind, and only then lets that user reach in.
*
* The user is therefore created by the inbound invite itself, which is the one code path whose
* result is consumed rather than discarded.
*/
(IS_EE ? describe : describe.skip)('Federation first contact', () => {
let rc1AdminRequestConfig: IRequestConfig;
let rc1InviteeRequestConfig: IRequestConfig;
let hs1FirstContactApp: SynapseClient;
const forgetRemoteUserLocally = async () => {
const known = await getUserByUsername(remoteUser.matrixUserId, rc1AdminRequestConfig);
if (known?._id) {
await deleteUser({ _id: known._id }, { confirmRelinquish: true }, rc1AdminRequestConfig);
}
};
beforeAll(async () => {
rc1AdminRequestConfig = await getRequestConfig(
federationConfig.rc1.url,
federationConfig.rc1.adminUser,
federationConfig.rc1.adminPassword,
);
const existingLocalUser = await getUserByUsername(localUser.username, rc1AdminRequestConfig);
if (!existingLocalUser?._id) {
await createUser(
{
username: localUser.username,
password: localUser.password,
email: `${localUser.username}@rocket.chat`,
name: localUser.username,
},
rc1AdminRequestConfig,
);
}
rc1InviteeRequestConfig = await getRequestConfig(federationConfig.rc1.url, localUser.username, localUser.password);
// a previous run leaves the user behind, which would make this spec exercise the warm path instead
await forgetRemoteUserLocally();
hs1FirstContactApp = new SynapseClient(federationConfig.hs1.url, remoteUser.username, remoteUser.password);
await hs1FirstContactApp.initialize();
}, 60000);
afterAll(async () => {
// leave the environment cold so a rerun against persistent containers still tests first contact
await forgetRemoteUserLocally();
await hs1FirstContactApp?.close();
});
it('should not know the remote user before the first interaction', async () => {
const response = await rc1AdminRequestConfig.request
.get(api('users.info'))
.set(rc1AdminRequestConfig.credentials)
.query({ username: remoteUser.matrixUserId });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('success', false);
});
describe('when the first interaction is an invite from Synapse', () => {
let channelName: string;
let synapseRoomId: string;
let federatedRoomId: string;
beforeAll(async () => {
channelName = `fed-first-contact-${Date.now()}`;
synapseRoomId = await hs1FirstContactApp.createRoom(channelName, Visibility.Private);
await hs1FirstContactApp.inviteUserToRoom(synapseRoomId, localUser.matrixUserId);
}, 60000);
it('should create the room on the Rocket.Chat side', async () => {
await retry(
'waiting for the federated room to reach RC',
async () => {
// rooms.get is scoped to the caller's subscriptions, and only the invitee is in this room
const response = await rc1InviteeRequestConfig.request.get(api('rooms.get')).set(rc1InviteeRequestConfig.credentials).expect(200);
const rcRoom = response.body.update.find(
(room: IRoomNativeFederated) => room.federation?.mrid === synapseRoomId,
) as IRoomNativeFederated | null;
expect(rcRoom).toBeTruthy();
federatedRoomId = rcRoom!._id;
},
{ retries: 10, delayMs: 2000 },
);
}, 30000);
// this covers the persisted document only. The shape of the object the invite handler reads back
// is not observable from here — it belongs to the createOrUpdateFederatedUser and
// getOrCreateFederatedUser unit tests, and when it regresses the room and subscription
// assertions below are what fail.
it('should persist the remote user locally as a complete document', async () => {
const response = await rc1AdminRequestConfig.request
.get(api('users.info'))
.set(rc1AdminRequestConfig.credentials)
.query({ username: remoteUser.matrixUserId })
.expect(200);
expect(response.body.user).toMatchObject({
username: remoteUser.matrixUserId,
name: remoteUser.matrixUserId,
type: 'user',
active: true,
federated: true,
roles: expect.arrayContaining(['federated-external']),
});
expect(response.body.user.createdAt).toBeTruthy();
});
it('should create the invited subscription for the local user', async () => {
const response = await rc1InviteeRequestConfig.request
.get(api('subscriptions.getOne'))
.set(rc1InviteeRequestConfig.credentials)
.query({ roomId: federatedRoomId })
.expect(200);
expect(response.body.subscription).toBeTruthy();
expect(response.body.subscription).toHaveProperty('status', 'INVITED');
});
it('should let the local user accept the invite', async () => {
const response = await acceptRoomInvite(federatedRoomId, rc1InviteeRequestConfig);
expect(response).toHaveProperty('success', true);
});
it('should reach the local user on the Synapse side', async () => {
await retry(
'waiting for the local user to appear as joined on Synapse',
async () => {
const member = await hs1FirstContactApp.findRoomMember(channelName, localUser.matrixUserId);
expect(member).not.toBeNull();
expect(member!.membership).toBe('join');
},
{ retries: 10, delayMs: 2000 },
);
}, 30000);
// a named, invite-only Synapse room becomes a private group on RC, so groups.members applies.
// it gates on canAccessRoomAsync without includeInvitations, meaning the accepted subscription
// has to have settled first — hence the retry and the ordering after the Synapse round-trip
it('should list the remote user among the room members', async () => {
await retry(
'waiting for the room members to include the remote user',
async () => {
const response = await rc1InviteeRequestConfig.request
.get(api('groups.members'))
.set(rc1InviteeRequestConfig.credentials)
.query({ roomId: federatedRoomId })
.expect(200);
expect(response.body.members).toEqual(
expect.arrayContaining([expect.objectContaining({ username: remoteUser.matrixUserId } as Partial<IUser>)]),
);
},
{ retries: 5, delayMs: 2000 },
);
}, 30000);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,890 @@
import type { IRoomNativeFederated, IUser } from '@rocket.chat/core-typings';
import type {} from '../../../../../apps/meteor/app/api/server/v1/permissions.ts';
import { api } from '../../../../../apps/meteor/tests/data/api-data';
import {
addUserToRoomViaMethod,
createRoom,
getSubscriptionByRoomId,
getSubscriptions,
} from '../../../../../apps/meteor/tests/data/rooms.helper';
import { createUser, deleteUser, getRequestConfig } from '../../../../../apps/meteor/tests/data/users.helper';
import type { IRequestConfig, TestUser } from '../../../../../apps/meteor/tests/data/users.helper';
import { IS_EE } from '../../../../../apps/meteor/tests/e2e/config/constants';
import { retry } from '../../../../../apps/meteor/tests/end-to-end/api/helpers/retry.ts';
import { federationConfig } from '../helper/config';
import { SynapseClient } from '../helper/synapse-client';
(IS_EE ? describe : describe.skip)('Federation Permissions', () => {
let rc1AdminRequestConfig: IRequestConfig;
let rc1User1RequestConfig: IRequestConfig;
let hs1AdminApp: SynapseClient;
beforeAll(async () => {
// Create admin request config for RC1
rc1AdminRequestConfig = await getRequestConfig(
federationConfig.rc1.url,
federationConfig.rc1.adminUser,
federationConfig.rc1.adminPassword,
);
// Create user1 request config for RC1
rc1User1RequestConfig = await getRequestConfig(
federationConfig.rc1.url,
federationConfig.rc1.additionalUser1.username,
federationConfig.rc1.additionalUser1.password,
);
// Create admin Synapse client for HS1
hs1AdminApp = new SynapseClient(federationConfig.hs1.url, federationConfig.hs1.adminUser, federationConfig.hs1.adminPassword);
await hs1AdminApp.initialize();
await rc1AdminRequestConfig.request
.post(api('permissions.update'))
.set(rc1AdminRequestConfig.credentials)
.send({ permissions: [{ _id: 'access-federation', roles: ['admin'] }] })
.expect('Content-Type', 'application/json')
.expect(200);
});
afterAll(async () =>
// Add permissions for access-federation to any user but admin
rc1AdminRequestConfig.request
.post(api('permissions.update'))
.set(rc1AdminRequestConfig.credentials)
.send({ permissions: [{ _id: 'access-federation', roles: ['admin', 'user'] }] })
.expect('Content-Type', 'application/json')
.expect(200),
);
afterAll(async () => hs1AdminApp.close());
describe('Access Federation Permission', () => {
describe('Users without access-federation permission', () => {
beforeAll(async () => {
await rc1AdminRequestConfig.request
.post(api('permissions.update'))
.set(rc1AdminRequestConfig.credentials)
.send({ permissions: [{ _id: 'access-federation', roles: ['admin'] }] })
.expect('Content-Type', 'application/json')
.expect(200);
});
afterAll(async () => {
// Add permissions for access-federation to any user but admin
await rc1AdminRequestConfig.request
.post(api('permissions.update'))
.set(rc1AdminRequestConfig.credentials)
.send({ permissions: [{ _id: 'access-federation', roles: ['admin', 'user'] }] })
.expect('Content-Type', 'application/json')
.expect(200);
});
describe('Inviting from a remote server', () => {
let user: TestUser<IUser>;
let matrixRoomId: string;
beforeAll(async () => {
user = await createUser(
{
username: `g3-${Date.now()}`,
password: '1',
roles: ['user'],
},
rc1AdminRequestConfig,
);
});
afterAll(async () => {
await deleteUser(user, {}, rc1AdminRequestConfig);
});
let channelName: string;
beforeAll(async () => {
channelName = `federated-room-${Date.now()}`;
matrixRoomId = await hs1AdminApp.createRoom(channelName);
});
it('should throw an error if a remote user tries to invite a user without access-federation permission to a room', async () => {
await expect(hs1AdminApp.matrixClient.invite(matrixRoomId, `@${user.username}:${federationConfig.rc1.domain}`)).rejects.toThrow();
const subscriptions = await getSubscriptions(rc1AdminRequestConfig);
const invitedSub = subscriptions.update.find((sub) => sub.fname?.includes(channelName));
expect(invitedSub).toBeUndefined();
});
it('should be able to invite a user to a room if the user has access-federation permission', async () => {
await expect(hs1AdminApp.matrixClient.invite(matrixRoomId, federationConfig.rc1.adminMatrixUserId)).resolves.not.toThrow();
await retry('waiting for invitation to be processed', async () => {
const subscriptions = await getSubscriptions(rc1AdminRequestConfig);
const pendingInvitation = subscriptions.update.find(
(subscription) => subscription.status === 'INVITED' && subscription.fname?.includes(channelName),
);
expect(pendingInvitation).not.toBeUndefined();
});
});
});
it('should throw an error if a user without access-federation permission tries to create a federated room', async () => {
const channelName = `federated-room-${Date.now()}`;
const createResponse = await createRoom({
type: 'p',
name: channelName,
members: [],
extraData: {
federated: true,
},
config: rc1User1RequestConfig,
});
expect(createResponse.status).toBe(400);
expect(createResponse.body).toHaveProperty('success', false);
expect(createResponse.body).toHaveProperty('errorType', 'error-not-authorized-federation');
});
describe('Inviting from a local server', () => {
let channelName: string;
let createResponse;
let addUserResponse;
beforeAll(async () => {
channelName = `federated-room-${Date.now()}`;
createResponse = await createRoom({
type: 'p',
name: channelName,
members: [],
extraData: {
federated: true,
},
config: rc1AdminRequestConfig,
});
expect(createResponse.status).toBe(200);
expect(createResponse.body).toHaveProperty('success', true);
expect(createResponse.body).toHaveProperty('group');
expect(createResponse.body.group).toHaveProperty('_id');
expect(createResponse.body.group).toHaveProperty('t', 'p');
expect(createResponse.body.group).toHaveProperty('federated', true);
});
let user: TestUser<IUser>;
beforeAll(async () => {
user = await createUser(
{
username: `g3-${Date.now()}`,
password: '1',
roles: ['user'],
},
rc1AdminRequestConfig,
);
});
afterAll(async () => {
await deleteUser(user, {}, rc1AdminRequestConfig);
});
it('should not be able to add a user without access-federation permission to a room', async () => {
const addUserResponse = await addUserToRoomViaMethod({
usernames: [user.username],
rid: createResponse.body.group._id,
config: rc1AdminRequestConfig,
});
expect(addUserResponse.status).toBe(400);
expect(addUserResponse.body).toHaveProperty('success', false);
expect(addUserResponse.body.message).toMatch(/error-not-authorized-federation/);
});
it("should be able to add a remote user to a room regardless of the user's access-federation permission defined locally", async () => {
addUserResponse = await addUserToRoomViaMethod({
usernames: [federationConfig.hs1.adminMatrixUserId],
rid: createResponse.body.group._id,
config: rc1AdminRequestConfig,
});
expect(addUserResponse.status).toBe(200);
expect(addUserResponse.body).toHaveProperty('success', true);
expect(addUserResponse.body).toHaveProperty('message');
expect(addUserResponse.body.message).toMatch('{"msg":"result","id":"id","result":true}');
});
});
});
describe('Users with access-federation permission', () => {
let user: TestUser<IUser>;
beforeAll(async () => {
user = await createUser(
{
username: `g3-${Date.now()}`,
password: '1',
roles: ['user', 'admin'],
},
rc1AdminRequestConfig,
);
});
afterAll(async () => {
await deleteUser(user, {}, rc1AdminRequestConfig);
});
it('should be able to create a federated room if the user has access-federation permission', async () => {
const channelName = `federated-room-${Date.now()}`;
const createResponse = await createRoom({
type: 'p',
name: channelName,
members: [],
extraData: {
federated: true,
},
config: rc1AdminRequestConfig,
});
expect(createResponse.status).toBe(200);
expect(createResponse.body).toHaveProperty('success', true);
expect(createResponse.body).toHaveProperty('group');
expect(createResponse.body.group).toHaveProperty('_id');
expect(createResponse.body.group).toHaveProperty('t', 'p');
expect(createResponse.body.group).toHaveProperty('federated', true);
});
describe('Add a user with access-federation permission to a room', () => {
beforeAll(async () =>
rc1AdminRequestConfig.request
.post(api('permissions.update'))
.set(rc1AdminRequestConfig.credentials)
.send({ permissions: [{ _id: 'access-federation', roles: ['admin', 'user'] }] })
.expect('Content-Type', 'application/json')
.expect(200),
);
it('should be able to add a user with access-federation permission to a room', async () => {
const createResponse = await createRoom({
type: 'p',
name: `federated-room-${Date.now()}`,
members: [],
extraData: {
federated: true,
},
config: rc1AdminRequestConfig,
}).expect(200);
const addUserResponse = await addUserToRoomViaMethod({
usernames: [user.username],
rid: createResponse.body.group._id,
config: rc1AdminRequestConfig,
}).expect(200);
expect(addUserResponse.body).toHaveProperty('success', true);
expect(addUserResponse.body).toHaveProperty('message');
expect(addUserResponse.body.message).toMatch('{"msg":"result","id":"id","result":true}');
});
});
});
});
describe('Federation_Service_Validate_User_Domain Setting', () => {
const rcValidUser1 = {
username: `valid-user1-${Date.now()}`,
fullName: `Valid User1 ${Date.now()}`,
get matrixId() {
return `@${this.username}:${federationConfig.rc1.domain}`;
},
config: {} as IRequestConfig,
user: {} as TestUser<IUser>,
};
const rcValidUser2 = {
username: `valid-user2-${Date.now()}`,
fullName: `Valid User2 ${Date.now()}`,
get matrixId() {
return `@${this.username}:${federationConfig.rc1.domain}`;
},
config: {} as IRequestConfig,
user: {} as TestUser<IUser>,
};
beforeAll(async () => {
// Ensure access-federation is granted to all users
await rc1AdminRequestConfig.request
.post(api('permissions.update'))
.set(rc1AdminRequestConfig.credentials)
.send({ permissions: [{ _id: 'access-federation', roles: ['admin', 'user'] }] })
.expect('Content-Type', 'application/json')
.expect(200);
// Create RC users
rcValidUser1.user = await createUser(
{
username: rcValidUser1.username,
password: 'random',
email: `${rcValidUser1.username}@${federationConfig.rc1.domain}`,
name: rcValidUser1.fullName,
},
rc1AdminRequestConfig,
);
await rc1AdminRequestConfig.request
.post(api('users.update'))
.set(rc1AdminRequestConfig.credentials)
.send({
userId: rcValidUser1.user._id,
data: {
verified: true,
},
})
.expect('Content-Type', 'application/json')
.expect(200);
rcValidUser1.config = await getRequestConfig(federationConfig.rc1.url, rcValidUser1.username, 'random');
rcValidUser2.user = await createUser(
{
username: rcValidUser2.username,
password: 'random',
email: `${rcValidUser2.username}@${federationConfig.rc1.domain}`,
name: rcValidUser2.fullName,
},
rc1AdminRequestConfig,
);
await rc1AdminRequestConfig.request
.post(api('users.update'))
.set(rc1AdminRequestConfig.credentials)
.send({
userId: rcValidUser2.user._id,
data: {
verified: true,
},
})
.expect('Content-Type', 'application/json')
.expect(200);
rcValidUser2.config = await getRequestConfig(federationConfig.rc1.url, rcValidUser2.username, 'random');
});
afterAll(async () => {
await Promise.all([
deleteUser(rcValidUser1.user, {}, rc1AdminRequestConfig),
deleteUser(rcValidUser2.user, {}, rc1AdminRequestConfig),
]);
});
describe('When setting is enabled', () => {
beforeAll(async () => {
// Enable email domain validation
await rc1AdminRequestConfig.request
.post(api('settings/Federation_Service_Validate_User_Domain'))
.set(rc1AdminRequestConfig.credentials)
.send({ value: true })
.expect('Content-Type', 'application/json')
.expect(200);
});
afterAll(async () => {
// Disable email domain validation
await rc1AdminRequestConfig.request
.post(api('settings/Federation_Service_Validate_User_Domain'))
.set(rc1AdminRequestConfig.credentials)
.send({ value: false })
.expect('Content-Type', 'application/json')
.expect(200);
});
describe('User with verified email matching federation domain', () => {
it('should be able to create a federated room', async () => {
const channelName = `federated-room-${Date.now()}`;
const createResponse = await createRoom({
type: 'p',
name: channelName,
members: [],
extraData: {
federated: true,
},
config: rcValidUser1.config,
});
expect(createResponse.status).toBe(200);
expect(createResponse.body).toHaveProperty('success', true);
expect(createResponse.body).toHaveProperty('group');
expect(createResponse.body.group).toHaveProperty('federated', true);
});
it('should be able to be added to a federated room', async () => {
const channelName = `federated-room-${Date.now()}`;
const createResponse = await createRoom({
type: 'p',
name: channelName,
members: [],
extraData: {
federated: true,
},
config: rcValidUser1.config,
});
expect(createResponse.status).toBe(200);
const addUserResponse = await addUserToRoomViaMethod({
usernames: [rcValidUser2.username],
rid: createResponse.body.group._id,
config: rcValidUser1.config,
});
expect(addUserResponse.status).toBe(200);
expect(addUserResponse.body).toHaveProperty('success', true);
});
it('should be able to be added to a federated room during creation', async () => {
const channelName = `federated-room-${Date.now()}`;
const createResponse = await createRoom({
type: 'p',
name: channelName,
members: [rcValidUser2.username],
extraData: {
federated: true,
},
config: rcValidUser1.config,
});
expect(createResponse.status).toBe(200);
expect(createResponse.body).toHaveProperty('success', true);
expect(createResponse.body).toHaveProperty('group');
expect(createResponse.body.group).toHaveProperty('federated', true);
});
});
describe('User with verified email NOT matching federation domain', () => {
let userWithNonMatchingEmail: TestUser<IUser>;
let userRequestConfig: IRequestConfig;
beforeAll(async () => {
userWithNonMatchingEmail = await createUser(
{
username: `user-nonmatching-${Date.now()}`,
password: 'password123',
email: `user-nonmatching-${Date.now()}@external-domain.com`,
roles: ['user'],
},
rc1AdminRequestConfig,
);
// Verify the user's email
await rc1AdminRequestConfig.request
.post(api('users.update'))
.set(rc1AdminRequestConfig.credentials)
.send({
userId: userWithNonMatchingEmail._id,
data: {
verified: true,
},
})
.expect('Content-Type', 'application/json')
.expect(200);
userRequestConfig = await getRequestConfig(federationConfig.rc1.url, userWithNonMatchingEmail.username, 'password123');
});
afterAll(async () => {
await deleteUser(userWithNonMatchingEmail, {}, rc1AdminRequestConfig);
});
it('should NOT be able to create a federated room', async () => {
const channelName = `federated-room-${Date.now()}`;
const createResponse = await createRoom({
type: 'p',
name: channelName,
members: [],
extraData: {
federated: true,
},
config: userRequestConfig,
});
expect(createResponse.status).toBe(400);
expect(createResponse.body).toHaveProperty('success', false);
expect(createResponse.body).toHaveProperty('errorType', 'error-not-authorized-federation');
});
it('should NOT be able to be added to a federated room', async () => {
const channelName = `federated-room-${Date.now()}`;
const createResponse = await createRoom({
type: 'p',
name: channelName,
members: [],
extraData: {
federated: true,
},
config: rcValidUser1.config,
});
expect(createResponse.status).toBe(200);
const addUserResponse = await addUserToRoomViaMethod({
usernames: [userWithNonMatchingEmail.username],
rid: createResponse.body.group._id,
config: rcValidUser1.config,
});
expect(addUserResponse.status).toBe(400);
expect(addUserResponse.body).toHaveProperty('success', false);
expect(addUserResponse.body.message).toMatch(/error-not-authorized-federation/);
});
it('should NOT be able to be added to a federated room during creation', async () => {
const channelName = `federated-room-${Date.now()}`;
const createResponse = await createRoom({
type: 'p',
name: channelName,
members: [userWithNonMatchingEmail.username],
extraData: {
federated: true,
},
config: userRequestConfig,
});
expect(createResponse.status).toBe(400);
expect(createResponse.body).toHaveProperty('success', false);
expect(createResponse.body).toHaveProperty('errorType', 'error-not-authorized-federation');
});
});
describe('User with unverified email matching federation domain', () => {
let userWithUnverifiedEmail: TestUser<IUser>;
let userRequestConfig: IRequestConfig;
beforeAll(async () => {
userWithUnverifiedEmail = await createUser(
{
username: `user-unverified-${Date.now()}`,
password: 'password123',
email: `user-unverified-${Date.now()}@${federationConfig.rc1.domain}`,
roles: ['user'],
},
rc1AdminRequestConfig,
);
userRequestConfig = await getRequestConfig(federationConfig.rc1.url, userWithUnverifiedEmail.username, 'password123');
});
afterAll(async () => {
await deleteUser(userWithUnverifiedEmail, {}, rc1AdminRequestConfig);
});
it('should NOT be able to create a federated room', async () => {
const channelName = `federated-room-${Date.now()}`;
const createResponse = await createRoom({
type: 'p',
name: channelName,
members: [],
extraData: {
federated: true,
},
config: userRequestConfig,
});
expect(createResponse.status).toBe(400);
expect(createResponse.body).toHaveProperty('success', false);
expect(createResponse.body).toHaveProperty('errorType', 'error-not-authorized-federation');
});
it('should NOT be able to be added to a federated room', async () => {
const channelName = `federated-room-${Date.now()}`;
const createResponse = await createRoom({
type: 'p',
name: channelName,
members: [],
extraData: {
federated: true,
},
config: rcValidUser1.config,
});
expect(createResponse.status).toBe(200);
const addUserResponse = await addUserToRoomViaMethod({
usernames: [userWithUnverifiedEmail.username],
rid: createResponse.body.group._id,
config: rcValidUser1.config,
});
expect(addUserResponse.status).toBe(400);
expect(addUserResponse.body).toHaveProperty('success', false);
expect(addUserResponse.body.message).toMatch(/error-not-authorized-federation/);
});
});
describe('User without email', () => {
let userWithoutEmail: TestUser<IUser>;
let userRequestConfig: IRequestConfig;
beforeAll(async () => {
userWithoutEmail = await createUser(
{
username: `user-noemail-${Date.now()}`,
password: 'password123',
roles: ['user'],
},
rc1AdminRequestConfig,
);
userRequestConfig = await getRequestConfig(federationConfig.rc1.url, userWithoutEmail.username, 'password123');
});
afterAll(async () => {
await deleteUser(userWithoutEmail, {}, rc1AdminRequestConfig);
});
it('should NOT be able to create a federated room', async () => {
const channelName = `federated-room-${Date.now()}`;
const createResponse = await createRoom({
type: 'p',
name: channelName,
members: [],
extraData: {
federated: true,
},
config: userRequestConfig,
});
expect(createResponse.status).toBe(400);
expect(createResponse.body).toHaveProperty('success', false);
expect(createResponse.body).toHaveProperty('errorType', 'error-not-authorized-federation');
});
it('should NOT be able to be added to a federated room', async () => {
const channelName = `federated-room-${Date.now()}`;
const createResponse = await createRoom({
type: 'p',
name: channelName,
members: [],
extraData: {
federated: true,
},
config: rcValidUser1.config,
});
expect(createResponse.status).toBe(200);
const addUserResponse = await addUserToRoomViaMethod({
usernames: [userWithoutEmail.username],
rid: createResponse.body.group._id,
config: rcValidUser1.config,
});
expect(addUserResponse.status).toBe(400);
expect(addUserResponse.body).toHaveProperty('success', false);
expect(addUserResponse.body.message).toMatch(/error-not-authorized-federation/);
});
});
describe('Remote invitations with email domain validation', () => {
let userWithMatchingEmail: TestUser<IUser>;
let userWithNonMatchingEmail: TestUser<IUser>;
let matrixRoomId: string;
let userWithMatchingEmailRequestConfig: IRequestConfig;
let userWithNonMatchingEmailRequestConfig: IRequestConfig;
const channelName = `federated-room-${Date.now()}`;
beforeAll(async () => {
userWithMatchingEmail = await createUser(
{
username: `user-remote-matching-${Date.now()}`,
password: 'password123',
email: `user-remote-matching-${Date.now()}@${federationConfig.rc1.domain}`,
roles: ['user'],
},
rc1AdminRequestConfig,
);
userWithMatchingEmailRequestConfig = await getRequestConfig(
federationConfig.rc1.url,
userWithMatchingEmail.username,
'password123',
);
// Verify the user's email
await rc1AdminRequestConfig.request
.post(api('users.update'))
.set(rc1AdminRequestConfig.credentials)
.send({
userId: userWithMatchingEmail._id,
data: {
verified: true,
},
})
.expect('Content-Type', 'application/json')
.expect(200);
userWithNonMatchingEmail = await createUser(
{
username: `user-remote-nonmatching-${Date.now()}`,
password: 'password123',
email: `user-remote-nonmatching-${Date.now()}@external-domain.com`,
roles: ['user'],
},
rc1AdminRequestConfig,
);
userWithNonMatchingEmailRequestConfig = await getRequestConfig(
federationConfig.rc1.url,
userWithNonMatchingEmail.username,
'password123',
);
// Verify the user's email
await rc1AdminRequestConfig.request
.post(api('users.update'))
.set(rc1AdminRequestConfig.credentials)
.send({
userId: userWithNonMatchingEmail._id,
data: {
verified: true,
},
})
.expect('Content-Type', 'application/json')
.expect(200);
matrixRoomId = await hs1AdminApp.createRoom(channelName);
});
afterAll(async () => {
await deleteUser(userWithMatchingEmail, {}, rc1AdminRequestConfig);
await deleteUser(userWithNonMatchingEmail, {}, rc1AdminRequestConfig);
});
it('should accept invitation for user with verified email matching federation domain', async () => {
await expect(
hs1AdminApp.matrixClient.invite(matrixRoomId, `@${userWithMatchingEmail.username}:${federationConfig.rc1.domain}`),
).resolves.not.toThrow();
await retry('this is an async operation, so we need to wait for the room to be created in RC', async () => {
const roomsResponse = await userWithMatchingEmailRequestConfig.request
.get(api('rooms.get'))
.set(userWithMatchingEmailRequestConfig.credentials)
.expect(200);
expect(roomsResponse.body).toHaveProperty('success', true);
expect(roomsResponse.body).toHaveProperty('update');
const rcRoomConverted = roomsResponse.body.update.find((room: IRoomNativeFederated) => room.federation.mrid === matrixRoomId);
expect(rcRoomConverted).toHaveProperty('_id');
expect(rcRoomConverted).toHaveProperty('t', 'p');
const subA = await getSubscriptionByRoomId(
rcRoomConverted._id,
userWithMatchingEmailRequestConfig.credentials,
userWithMatchingEmailRequestConfig.request,
);
expect(subA).toHaveProperty('fname', `${channelName}:${federationConfig.hs1.domain}`);
expect(subA).toHaveProperty('status', 'INVITED');
});
});
it('should reject invitation for user with verified email NOT matching federation domain', async () => {
await expect(
hs1AdminApp.matrixClient.invite(matrixRoomId, `@${userWithNonMatchingEmail.username}:${federationConfig.rc1.domain}`),
).rejects.toThrow();
const subscriptions = await getSubscriptions(userWithNonMatchingEmailRequestConfig);
const invitedSub = subscriptions.update.find((sub) => sub.fname === `${channelName}:${federationConfig.hs1.domain}`);
expect(invitedSub).toBeUndefined();
});
});
});
describe('When setting is disabled', () => {
beforeAll(async () => {
// Disable email domain validation
await rc1AdminRequestConfig.request
.post(api('settings/Federation_Service_Validate_User_Domain'))
.set(rc1AdminRequestConfig.credentials)
.send({ value: false })
.expect('Content-Type', 'application/json')
.expect(200);
});
describe('User with any email domain', () => {
let userWithNonMatchingEmail: TestUser<IUser>;
let userRequestConfig: IRequestConfig;
beforeAll(async () => {
userWithNonMatchingEmail = await createUser(
{
username: `user-any-domain-${Date.now()}`,
password: 'password123',
email: `user-any-domain-${Date.now()}@external-domain.com`,
roles: ['user'],
},
rc1AdminRequestConfig,
);
// Verify the user's email
await rc1AdminRequestConfig.request
.post(api('users.update'))
.set(rc1AdminRequestConfig.credentials)
.send({
userId: userWithNonMatchingEmail._id,
data: {
verified: true,
},
})
.expect('Content-Type', 'application/json')
.expect(200);
userRequestConfig = await getRequestConfig(federationConfig.rc1.url, userWithNonMatchingEmail.username, 'password123');
});
afterAll(async () => {
await deleteUser(userWithNonMatchingEmail, {}, rc1AdminRequestConfig);
});
it('should be able to create a federated room regardless of email domain', async () => {
const channelName = `federated-room-${Date.now()}`;
const createResponse = await createRoom({
type: 'p',
name: channelName,
members: [],
extraData: {
federated: true,
},
config: userRequestConfig,
});
expect(createResponse.status).toBe(200);
expect(createResponse.body).toHaveProperty('success', true);
expect(createResponse.body).toHaveProperty('group');
expect(createResponse.body.group).toHaveProperty('federated', true);
});
it('should be able to be added to a federated room regardless of email domain', async () => {
const channelName = `federated-room-${Date.now()}`;
const createResponse = await createRoom({
type: 'p',
name: channelName,
members: [],
extraData: {
federated: true,
},
config: rc1AdminRequestConfig,
});
expect(createResponse.status).toBe(200);
const addUserResponse = await addUserToRoomViaMethod({
usernames: [userWithNonMatchingEmail.username],
rid: createResponse.body.group._id,
config: rc1AdminRequestConfig,
});
expect(addUserResponse.status).toBe(200);
expect(addUserResponse.body).toHaveProperty('success', true);
});
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,136 @@
/**
* Configuration interface for federation test environment.
*
* Defines the structure for all federation-related configuration including
* Rocket.Chat instances, Matrix homeservers, and user credentials needed
* for end-to-end federation testing.
*/
type FederationUserConfig = {
username: string;
password: string;
matrixUserId: string;
};
type FederationServerConfig = {
url: string;
domain: string;
adminUser: string;
adminPassword: string;
adminMatrixUserId: string;
additionalUser1: FederationUserConfig;
/**
* Reserved for tests that need a user the other side has never seen. Must not be used
* anywhere else: the moment another spec touches it, the local user document exists and
* first-contact behaviour can no longer be observed.
*/
firstContactUser: FederationUserConfig;
};
export interface IFederationConfig {
rc1: FederationServerConfig;
hs1: FederationServerConfig;
}
/**
* Validates that a required environment variable exists and is not empty.
*
* Ensures that all federation test configuration is properly set by validating
* environment variables and providing sensible defaults where appropriate.
* Throws an error if a required variable is missing or empty.
*
* @param name - The name of the environment variable for error messages
* @param defaultValue - Optional default value to use if variable is not set
* @returns The validated value (either the env var or default)
* @throws Error if the variable is required but missing or empty
*/
function validateEnvVar(name: string, defaultValue?: string): string {
const finalValue = process.env[name] || defaultValue;
if (!finalValue || finalValue.trim() === '') {
throw new Error(`Required environment variable ${name} is not set or is empty`);
}
return finalValue;
}
/**
* Builds and validates the complete federation test configuration.
*
* Reads all federation-related environment variables, validates them,
* and constructs a complete configuration object. Uses sensible defaults
* for development and testing scenarios while ensuring all required
* values are present.
*
* @returns Complete federation configuration object
* @throws Error if any required configuration is missing or invalid
*/
function getFederationConfig(): IFederationConfig {
const rcDomain = validateEnvVar('FEDERATION_RC1_DOMAIN', 'rc1');
const rcAdminUser = validateEnvVar('FEDERATION_RC1_ADMIN_USER', 'admin');
const rcAdminPassword = validateEnvVar('FEDERATION_RC1_ADMIN_PASSWORD', 'admin');
const rcAdditionalUser1 = validateEnvVar('FEDERATION_RC1_ADDITIONAL_USER1', 'user2');
const rcAdditionalUser1Password = validateEnvVar('FEDERATION_RC1_ADDITIONAL_USER1_PASSWORD', 'user2pass');
const rcFirstContactUser = validateEnvVar('FEDERATION_RC1_FIRST_CONTACT_USER', 'user3');
const rcFirstContactUserPassword = validateEnvVar('FEDERATION_RC1_FIRST_CONTACT_USER_PASSWORD', 'user3pass');
const hs1Domain = validateEnvVar('FEDERATION_SYNAPSE_DOMAIN', 'hs1');
const hs1AdminUser = validateEnvVar('FEDERATION_SYNAPSE_ADMIN_USER', 'admin');
const hs1AdminPassword = validateEnvVar('FEDERATION_SYNAPSE_ADMIN_PASSWORD', 'admin');
const hs1AdditionalUser1 = validateEnvVar('FEDERATION_SYNAPSE_ADDITIONAL_USER1', 'alice');
const hs1AdditionalUser1Password = validateEnvVar('FEDERATION_SYNAPSE_ADDITIONAL_USER1_PASSWORD', 'alice');
// `cleiton` is registered by docker-compose.test.yml and deliberately used by no other spec
const hs1FirstContactUser = validateEnvVar('FEDERATION_SYNAPSE_FIRST_CONTACT_USER', 'cleiton');
const hs1FirstContactUserPassword = validateEnvVar('FEDERATION_SYNAPSE_FIRST_CONTACT_USER_PASSWORD', 'cleiton');
return {
rc1: {
url: `https://${rcDomain}`,
domain: rcDomain,
adminUser: rcAdminUser,
adminPassword: rcAdminPassword,
adminMatrixUserId: `@${rcAdminUser}:${rcDomain}`,
additionalUser1: {
username: rcAdditionalUser1,
password: rcAdditionalUser1Password,
matrixUserId: `@${rcAdditionalUser1}:${rcDomain}`,
},
firstContactUser: {
username: rcFirstContactUser,
password: rcFirstContactUserPassword,
matrixUserId: `@${rcFirstContactUser}:${rcDomain}`,
},
},
hs1: {
url: `https://${hs1Domain}`,
domain: hs1Domain,
adminUser: hs1AdminUser,
adminMatrixUserId: `@${hs1AdminUser}:${hs1Domain}`,
adminPassword: hs1AdminPassword,
additionalUser1: {
username: hs1AdditionalUser1,
password: hs1AdditionalUser1Password,
matrixUserId: `@${hs1AdditionalUser1}:${hs1Domain}`,
},
firstContactUser: {
username: hs1FirstContactUser,
password: hs1FirstContactUserPassword,
matrixUserId: `@${hs1FirstContactUser}:${hs1Domain}`,
},
},
};
}
/**
* Validated federation configuration for test execution.
*
* This configuration is loaded at module initialization time and
* will cause the process to exit if any required environment
* variables are missing or invalid.
*/
let federationConfig: IFederationConfig;
try {
federationConfig = getFederationConfig();
} catch (error) {
console.error('Federation environment configuration error:', error);
process.exit(1);
}
export { federationConfig };

View File

@@ -0,0 +1,168 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { DDPSDK } from '@rocket.chat/ddp-client';
import type { IRequestConfig } from '../../../../../apps/meteor/tests/data/users.helper';
/**
* DDP Listener for catching ephemeral messages in federation tests
*
* This helper creates a DDP connection to listen for ephemeral messages
* that are broadcast to a specific user. It's designed to work with
* the federation test environment where the test runs separately from
* the server.
*/
export class DDPListener {
private sdk: DDPSDK | null = null;
private ephemeralMessages: IMessage[] = [];
private timeoutId: NodeJS.Timeout | null = null;
private serverUrl: string;
private userId: string;
private authToken?: string;
constructor(apiUrl: string, requestConfig: IRequestConfig) {
// Extract server URL from API URL (convert HTTP/HTTPS to WebSocket)
this.serverUrl = apiUrl.replace(/^http/, 'ws');
// Extract user ID and auth token from request config credentials
this.userId = requestConfig.credentials['X-User-Id'];
this.authToken = requestConfig.credentials['X-Auth-Token'];
}
/**
* Connect to the DDP server and subscribe to ephemeral messages
*/
async connect(): Promise<void> {
try {
// Create DDP SDK instance
this.sdk = DDPSDK.create(this.serverUrl);
// Add timeout for connection
const connectionTimeout = setTimeout(() => {
throw new Error('DDP connection timeout');
}, 10000);
// Connect to the server
await this.sdk.connection.connect();
// Wait a bit for the connection to be fully ready
await new Promise((resolve) => setTimeout(resolve, 1000));
// Authenticate if we have a token
if (this.authToken) {
await this.sdk.account.loginWithToken(this.authToken);
}
// Subscribe to ephemeral messages using the stream method
this.sdk.stream('notify-user', `${this.userId}/message`, (...args) => {
// The args should contain the ephemeral message
if (args && args.length > 0) {
const message = args[0] as IMessage;
this.ephemeralMessages.push(message);
}
});
// Clear timeout on successful connection
clearTimeout(connectionTimeout);
} catch (error) {
throw error;
}
}
/**
* Wait for an ephemeral message with a specific content
* @param expectedContent - The expected message content (partial match)
* @param timeoutMs - Timeout in milliseconds (default: 5000)
* @param roomId - Optional room ID to validate the message belongs to the correct room
* @returns Promise that resolves with the message or rejects on timeout
*/
async waitForEphemeralMessage(expectedContent: string, timeoutMs = 5000, roomId?: string): Promise<IMessage> {
return new Promise((resolve, reject) => {
// Check if message already exists
const existingMessage = this.ephemeralMessages.find((msg) => {
const contentMatches = msg.msg?.includes(expectedContent);
const roomMatches = !roomId || msg.rid === roomId;
return contentMatches && roomMatches;
});
if (existingMessage) {
resolve(existingMessage);
return;
}
let interval: NodeJS.Timeout | null = null;
this.timeoutId = setTimeout(() => {
if (interval) {
clearInterval(interval);
}
const roomInfo = roomId ? ` for room ${roomId}` : '';
reject(new Error(`Timeout waiting for ephemeral message containing: "${expectedContent}"${roomInfo}`));
}, timeoutMs);
const checkMessages = () => {
const message = this.ephemeralMessages.find((msg) => {
const contentMatches = msg.msg?.includes(expectedContent);
const roomMatches = !roomId || msg.rid === roomId;
return contentMatches && roomMatches;
});
if (message) {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
if (interval) {
clearInterval(interval);
}
resolve(message);
}
};
interval = setInterval(checkMessages, 100);
});
}
/**
* Get all captured ephemeral messages
*/
getEphemeralMessages(): IMessage[] {
return [...this.ephemeralMessages];
}
/**
* Clear captured messages
*/
clearMessages(): void {
this.ephemeralMessages = [];
}
/**
* Disconnect from DDP server
*/
disconnect(): void {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
if (this.sdk) {
this.sdk.connection.close();
this.sdk = null;
}
}
}
/**
* Helper function to create and manage a DDP listener for federation tests
* @param apiUrl - The Rocket.Chat API URL (e.g., 'http://rc1:3000' or 'https://rc1:3000')
* @param requestConfig - The request configuration containing credentials
* @returns DDPListener instance
*/
export function createDDPListener(apiUrl: string, requestConfig: IRequestConfig): DDPListener {
return new DDPListener(apiUrl, requestConfig);
}

View File

@@ -0,0 +1,742 @@
/**
* Federation test data and configuration
* This file provides validated federation configuration for federation tests.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { createClient, type MatrixClient, KnownMembership, type Room, type RoomMember, Visibility } from 'matrix-js-sdk';
import { logger } from 'matrix-js-sdk/lib/logger';
logger.debug = () => void 0;
logger.info = () => void 0;
logger.warn = () => void 0;
logger.error = () => void 0;
/**
* Creates a promise that resolves after the specified delay.
*
* Utility function for adding delays in async operations, particularly
* useful for retry logic and handling eventual consistency in distributed systems.
*
* @param ms - The delay in milliseconds
* @returns Promise that resolves after the specified delay
*/
export function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Client for interacting with Matrix Synapse homeserver during federation tests.
*
* Provides a simplified interface for Matrix operations needed in federation
* testing scenarios, including room management, member operations, and
* invitation handling with built-in retry logic for eventual consistency.
*/
export class SynapseClient {
private _matrixClient: MatrixClient | null = null;
private url: string;
private username: string;
private password: string;
/**
* Creates a new SynapseClient instance.
*
* @param url - The Matrix homeserver URL
* @param username - Matrix user ID (e.g., @user:domain.com)
* @param password - User password for authentication
*/
constructor(url: string, username: string, password: string) {
this.url = url;
this.username = username;
this.password = password;
}
get matrixClient(): MatrixClient {
if (!this._matrixClient) {
throw new Error('Matrix client is not initialized');
}
return this._matrixClient;
}
/**
* Initializes the Matrix client connection.
*
* Creates and authenticates a Matrix client, then starts the client
* to enable real-time operations. Must be called before using other methods.
*
* @returns Promise that resolves when initialization is complete
* @throws Error if authentication fails or client cannot be started
*/
async initialize(): Promise<void> {
const client = await this.createClient(this.username, this.password, this.url);
await client.startClient();
this._matrixClient = client;
}
/**
* Creates and authenticates a Matrix client with silent logging.
*
* Sets up a Matrix client with minimal logging to reduce noise during
* test execution while maintaining full functionality for federation testing.
*
* @param username - Matrix user ID for authentication
* @param password - User password for authentication
* @param url - Matrix homeserver URL
* @returns Authenticated Matrix client ready for use
* @throws Error if login fails or client creation fails
*/
private async createClient(username: string, password: string, url: string): Promise<MatrixClient> {
const silentLogger = {
// eslint-disable-next-line @typescript-eslint/no-empty-function
trace: () => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
debug: () => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
info: () => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
warn: () => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
error: () => {},
getChild: () => silentLogger,
};
const client = createClient({
baseUrl: url,
useAuthorizationHeader: true,
logger: silentLogger,
});
await client.login('m.login.password', {
user: username,
password,
});
return client;
}
/**
* Retrieves a room by its display name.
*
* Searches through all known rooms to find one matching the specified
* display name. Useful for federation testing where room names are
* used as identifiers.
*
* @param roomName - The display name of the room to find
* @returns The Matrix room object
* @throws Error if client is not initialized or room is not found
*/
getRoom(roomName: string): Room {
if (!this.matrixClient) {
throw new Error('Matrix client is not initialized');
}
const rooms = this.matrixClient.getRooms();
const room = rooms.find((room) => room.name === roomName);
if (room) {
return room;
}
throw new Error(`No room found with name ${roomName}`);
}
async createRoom(roomName: string, visibility: Visibility = Visibility.Private): Promise<string> {
if (!this.matrixClient) {
throw new Error('Matrix client is not initialized');
}
const room = await this.matrixClient.createRoom({
name: roomName,
visibility,
});
return room.room_id;
}
async createDM(userIds: string[]) {
if (!this.matrixClient) {
throw new Error('Matrix client is not initialized');
}
const dmRoom = await this.matrixClient.createRoom({
is_direct: true,
invite: userIds,
});
return this.matrixClient.getRoom(dmRoom.room_id);
}
async inviteUserToRoom(roomId: string, userId: string): Promise<void> {
if (!this.matrixClient) {
throw new Error('Matrix client is not initialized');
}
await this.matrixClient.invite(roomId, userId);
}
/**
* Finds a room by name and membership status.
*
* Searches for a room that matches both the display name and the current
* user's membership status. Useful for finding rooms in specific states
* like 'invite' or 'join' during federation testing.
*
* @param roomName - The display name of the room to find
* @param membership - The required membership status (e.g., 'invite', 'join')
* @returns The Matrix room ID
* @throws Error if client is not initialized or room is not found
*/
getRoomIdByRoomNameAndMembership(roomName: string, membership: KnownMembership): string {
if (!this.matrixClient) {
throw new Error('Matrix client is not initialized');
}
const rooms = this.matrixClient.getRooms();
const room = rooms.find((room) => room.name === roomName && room.getMyMembership() === membership);
if (room) {
return room.roomId;
}
throw new Error(`No room found with name ${roomName} and membership ${membership}`);
}
/**
* Accepts a room invitation with configurable retry logic.
*
* Handles the process of accepting a room invitation, which is common
* in federation scenarios where users are invited to remote rooms.
* Includes retry logic to handle eventual consistency in distributed systems.
*
* @param roomName - The display name of the room to join
* @param maxRetries - Maximum number of retry attempts (default: 5)
* @param retryDelay - Delay between retries in milliseconds (default: 1000)
* @param initialDelay - Initial delay before first attempt in milliseconds (default: 5000)
* @returns The room ID of the successfully joined room
* @throws Error if client is not initialized or all retry attempts fail
*/
async acceptInvitationForRoomName(roomName: string, maxRetries = 5, retryDelay = 1000, initialDelay = 5000): Promise<string> {
if (!this.matrixClient) {
throw new Error('Matrix client is not initialized');
}
if (initialDelay) {
await wait(initialDelay);
}
const retries = Math.max(1, maxRetries);
let lastError: Error | null = null;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const roomId = this.getRoomIdByRoomNameAndMembership(roomName, KnownMembership.Invite);
await this.matrixClient.joinRoom(roomId);
return roomId;
} catch (error) {
if (attempt < retries) {
await wait(retryDelay);
}
lastError = error as Error;
}
}
throw new Error(
`Failed to accept invitation for room ${roomName} after ${retries} attempts${lastError ? `: ${lastError.message}` : ''}`,
);
}
/**
* Retrieves all members of a room.
*
* Gets the complete list of room members, which is essential
* for verifying federation state and member synchronization.
*
* @param roomName - The display name of the room
* @returns Array of room member objects
* @throws Error if client is not initialized or room is not found
*/
async getRoomMembers(roomName: string): Promise<RoomMember[]> {
const room = this.getRoom(roomName);
return room.getMembers();
}
/**
* Finds a specific room member with retry logic.
*
* Searches for a member in a room by username or user ID, with configurable
* retry logic to handle eventual consistency in federated systems.
* This is crucial for federation testing where member synchronization
* may take time to propagate across homeservers.
*
* @param roomName - The display name of the room to search
* @param username - The username or user ID to find
* @param options - Retry configuration options
* @param options.maxRetries - Maximum number of retry attempts (default: 3)
* @param options.delay - Delay between retries in milliseconds (default: 1000)
* @param options.initialDelay - Initial delay before first attempt in milliseconds (default: 0)
* @returns The room member if found, null otherwise
*/
async findRoomMember(
roomName: string,
username: string,
options: { maxRetries?: number; delay?: number; initialDelay?: number } = {},
): Promise<RoomMember | null> {
const { maxRetries = 3, delay = 1000, initialDelay = 0 } = options;
if (initialDelay > 0) {
await wait(initialDelay);
}
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const members = await this.getRoomMembers(roomName);
const member = members.find((member: RoomMember) => member.name === username || member.userId === username);
if (member) {
return member;
}
if (attempt < maxRetries) {
await wait(delay);
}
} catch (error) {
console.warn(`Attempt ${attempt} to find room member failed:`, error);
if (attempt < maxRetries) {
await wait(delay);
}
}
}
return null;
}
/**
* Sends a text message to a room.
*
* Sends a plain text message to the specified room using the Matrix JS SDK.
* The room is identified by its display name, which is common in federation
* testing scenarios.
*
* @param roomName - The display name of the room to send the message to
* @param message - The text message content to send
* @returns Promise resolving to the Matrix event ID of the sent message
* @throws Error if client is not initialized or room is not found
*/
async sendTextMessage(roomName: string, message: string): Promise<string> {
if (!this.matrixClient) {
throw new Error('Matrix client is not initialized');
}
const room = this.getRoom(roomName);
const response = await this.matrixClient.sendTextMessage(room.roomId, message);
return response.event_id;
}
/**
* Sends an HTML-formatted message to a room.
*
* Sends a message with HTML formatting to the specified room using the Matrix JS SDK.
* This allows sending formatted text (bold, italic, underline) that will be properly
* rendered in Element and other Matrix clients that support HTML formatting.
*
* @param roomName - The display name of the room to send the message to
* @param body - The plain text version of the message (required by Matrix spec)
* @param formattedBody - The HTML-formatted version of the message
* @returns Promise resolving to the Matrix event ID of the sent message
* @throws Error if client is not initialized or room is not found
*/
async sendHtmlMessage(roomName: string, body: string, formattedBody: string): Promise<string> {
if (!this.matrixClient) {
throw new Error('Matrix client is not initialized');
}
const room = this.getRoom(roomName);
const content: any = {
msgtype: 'm.text',
body,
format: 'org.matrix.custom.html',
formatted_body: formattedBody,
};
const response = await this.matrixClient.sendMessage(room.roomId, content);
return response.event_id;
}
/**
* Retrieves all text messages from a room's timeline.
*
* Gets all text message events from the room's timeline, which is essential
* for verifying message synchronization in federation testing. Filters out
* non-message events and returns only text messages.
*
* @param roomName - The display name of the room
* @returns Array of text message events from the room's timeline
* @throws Error if client is not initialized or room is not found
*/
getRoomMessages(roomName: string): Array<{ content: { body: string }; event_id: string; sender: string }> {
if (!this.matrixClient) {
throw new Error('Matrix client is not initialized');
}
const room = this.getRoom(roomName);
const { timeline } = room;
const messages: Array<{ content: { body: string }; event_id: string; sender: string }> = [];
for (const event of timeline) {
if (event.getType() === 'm.room.message') {
const content = event.getContent();
if (content.msgtype === 'm.text' || content.msgtype === 'm.notice') {
messages.push({
content: {
body: content.body || '',
},
event_id: event.getId() || '',
sender: event.getSender() || '',
});
}
}
}
return messages;
}
/**
* Finds a message in a room's timeline by content.
*
* Searches for a message in the room's timeline that matches the specified
* content text. Useful for verifying that messages appear correctly on
* the remote side in federation tests.
*
* @param roomName - The display name of the room to search
* @param messageText - The message text to find
* @param options - Retry configuration options
* @param options.maxRetries - Maximum number of retry attempts (default: 5)
* @param options.delay - Delay between retries in milliseconds (default: 1000)
* @param options.initialDelay - Initial delay before first attempt in milliseconds (default: 2000)
* @returns The message event if found, null otherwise
*/
async findMessageInRoom(
roomName: string,
messageText: string,
options: { maxRetries?: number; delay?: number; initialDelay?: number } = {},
): Promise<{ content: { body: string }; event_id: string; sender: string } | null> {
const { maxRetries = 5, delay = 1000, initialDelay = 2000 } = options;
if (initialDelay > 0) {
await wait(initialDelay);
}
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const messages = this.getRoomMessages(roomName);
const message = messages.find((msg) => msg.content.body === messageText);
if (message) {
return message;
}
if (attempt < maxRetries) {
await wait(delay);
}
} catch (error) {
console.warn(`Attempt ${attempt} to find message in room failed:`, error);
if (attempt < maxRetries) {
await wait(delay);
}
}
}
return null;
}
/**
* Uploads a file to a room using Matrix JS SDK.
*
* Uploads a file to the specified room and sends it as a file message.
* Determines the appropriate msgtype based on file extension and mime type.
*
* @param roomName - The display name of the room to upload the file to
* @param filePath - Path to the file to upload
* @param fileName - The file name to use in the message body (used by findFileMessageInRoom)
* @returns Promise resolving to the Matrix event ID of the sent file message
* @throws Error if client is not initialized or room is not found
*/
async uploadFile(roomName: string, filePath: string, fileName: string): Promise<string> {
if (!this.matrixClient) {
throw new Error('Matrix client is not initialized');
}
const room = this.getRoom(roomName);
// Read file
const fileBuffer = fs.readFileSync(filePath);
const fileExtension = path.extname(fileName).toLowerCase().slice(1);
// Determine mime type based on extension
const mimeTypes: Record<string, string> = {
webp: 'image/webp',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
pdf: 'application/pdf',
webm: 'video/webm',
mp4: 'video/mp4',
mp3: 'audio/mpeg',
wav: 'audio/wav',
txt: 'text/plain',
};
const mimeType = mimeTypes[fileExtension] || 'application/octet-stream';
// Determine msgtype based on file type
let msgtype: string;
if (mimeType.startsWith('image/')) {
msgtype = 'm.image';
} else if (mimeType.startsWith('video/')) {
msgtype = 'm.video';
} else if (mimeType.startsWith('audio/')) {
msgtype = 'm.audio';
} else {
msgtype = 'm.file';
}
// Upload file content
const uploadResponse = await this.matrixClient.uploadContent(fileBuffer, {
name: fileName,
type: mimeType,
});
if (!uploadResponse.content_uri) {
throw new Error('File upload failed: no content URI returned');
}
// Send file message
const content: any = {
msgtype,
body: fileName,
url: uploadResponse.content_uri,
info: {
mimetype: mimeType,
size: fileBuffer.length,
},
};
const response = await this.matrixClient.sendMessage(room.roomId, content);
return response.event_id;
}
/**
* Retrieves all file/media messages from a room's timeline.
*
* Gets all file message events (images, videos, audio, files) from the room's timeline.
* Useful for verifying file synchronization in federation testing.
*
* @param roomName - The display name of the room
* @returns Array of file message events from the room's timeline
* @throws Error if client is not initialized or room is not found
*/
getRoomFileMessages(roomName: string): Array<{
content: { body: string; msgtype: string; url?: string; info?: any };
event_id: string;
sender: string;
}> {
if (!this.matrixClient) {
throw new Error('Matrix client is not initialized');
}
const room = this.getRoom(roomName);
const { timeline } = room;
const messages: Array<{
content: { body: string; msgtype: string; url?: string; info?: any };
event_id: string;
sender: string;
}> = [];
for (const event of timeline) {
if (event.getType() === 'm.room.message') {
const content = event.getContent();
if (
content.msgtype === 'm.image' ||
content.msgtype === 'm.video' ||
content.msgtype === 'm.audio' ||
content.msgtype === 'm.file'
) {
messages.push({
content: {
body: content.body || '',
msgtype: content.msgtype,
url: content.url,
info: content.info,
},
event_id: event.getId() || '',
sender: event.getSender() || '',
});
}
}
}
return messages;
}
/**
* Finds a file message in a room's timeline by file name.
*
* Searches for a file message in the room's timeline that matches the specified
* file name. Useful for verifying that file messages appear correctly on
* the remote side in federation tests.
*
* @param roomName - The display name of the room to search
* @param fileName - The file name to find
* @param options - Retry configuration options
* @param options.maxRetries - Maximum number of retry attempts (default: 5)
* @param options.delay - Delay between retries in milliseconds (default: 1000)
* @param options.initialDelay - Initial delay before first attempt in milliseconds (default: 2000)
* @returns The file message event if found, null otherwise
*/
async findFileMessageInRoom(
roomName: string,
fileName: string,
options: { maxRetries?: number; delay?: number; initialDelay?: number } = {},
): Promise<{
content: { body: string; msgtype: string; url?: string; info?: any };
event_id: string;
sender: string;
} | null> {
const { maxRetries = 5, delay = 1000, initialDelay = 2000 } = options;
if (initialDelay > 0) {
await wait(initialDelay);
}
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const messages = this.getRoomFileMessages(roomName);
const message = messages.find((msg) => msg.content.body === fileName || msg.content.body?.includes(fileName));
if (message) {
return message;
}
if (attempt < maxRetries) {
await wait(delay);
}
} catch (error) {
console.warn(`Attempt ${attempt} to find file message in room failed:`, error);
if (attempt < maxRetries) {
await wait(delay);
}
}
}
return null;
}
/**
* Downloads a file from Matrix and verifies it matches the original file using binary comparison.
*
* Uses the Matrix JS SDK to download media files from the homeserver and compares
* them byte-by-byte with the original file. The MXC URI format is: mxc://serverName/mediaId
*
* @param mxcUri - The MXC URI of the media to download (e.g., "mxc://serverName/mediaId")
* @param originalFilePath - Path to the original file to compare against
* @returns Promise resolving to true if files match byte-by-byte
* @throws Error if client is not initialized or download fails
*/
async downloadFileAndCompareBinary(mxcUri: string, originalFilePath: string): Promise<boolean> {
if (!this.matrixClient) {
throw new Error('Matrix client is not initialized');
}
try {
// Use Matrix JS SDK's mxcUrlToHttp with useAuthentication=true to get the client v1 endpoint
// This generates: https://hs1/_matrix/client/v1/media/download/{serverName}/{mediaId}?allow_redirect=true
// Parameters: mxcUrl, width, height, resizeMethod, allowDirectLinks, allowRedirects, useAuthentication
const downloadUrl = this.matrixClient.mxcUrlToHttp(mxcUri, undefined, undefined, undefined, false, true, true);
if (!downloadUrl) {
throw new Error(`Failed to convert MXC URI to HTTP URL: ${mxcUri}`);
}
// Add allow_remote=true parameter to ensure Synapse fetches from remote servers
const urlWithRemote = new URL(downloadUrl);
urlWithRemote.searchParams.set('allow_remote', 'true');
const finalDownloadUrl = urlWithRemote.toString();
const accessToken = this.matrixClient.getAccessToken();
if (!accessToken) {
throw new Error('Matrix client access token not available');
}
const response = await fetch(finalDownloadUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
redirect: 'follow',
});
if (!response.ok) {
throw new Error(`Failed to download media: ${response.status} ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
const downloadedBuffer = Buffer.from(arrayBuffer);
// Read the original file
const originalBuffer = fs.readFileSync(originalFilePath);
// Compare buffers byte-by-byte
if (downloadedBuffer.length !== originalBuffer.length) {
return false;
}
return downloadedBuffer.equals(originalBuffer);
} catch (error) {
throw new Error(`Failed to download and compare media from ${mxcUri}: ${error}`);
}
}
/**
* Bans a user from a room on the Synapse homeserver.
*
* @param roomId - The Matrix room ID
* @param userId - The Matrix user ID to ban
* @param reason - Optional reason for the ban
*/
async banUser(roomId: string, userId: string, reason?: string): Promise<void> {
if (!this.matrixClient) {
throw new Error('Matrix client is not initialized');
}
await this.matrixClient.ban(roomId, userId, reason);
}
/**
* Unbans a user from a room on the Synapse homeserver.
*
* @param roomId - The Matrix room ID
* @param userId - The Matrix user ID to unban
*/
async unbanUser(roomId: string, userId: string): Promise<void> {
if (!this.matrixClient) {
throw new Error('Matrix client is not initialized');
}
await this.matrixClient.unban(roomId, userId);
}
/**
* Closes the Matrix client connection and cleans up resources.
*
* Properly shuts down the Matrix client, clears all data stores,
* removes event listeners, and logs out. Essential for preventing
* resource leaks during test execution.
*
* @returns Promise that resolves when cleanup is complete
*/
async close(): Promise<void> {
if (this.matrixClient) {
this.matrixClient.stopClient();
await this.matrixClient.store?.deleteAllData?.();
await this.matrixClient.clearStores?.();
this.matrixClient.removeAllListeners();
await this.matrixClient.logout(true);
this._matrixClient = null;
}
}
}

View File

@@ -0,0 +1,11 @@
export function withTimeout<T>(fn: (signal: AbortSignal) => Promise<T>, ms: number): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, ms);
return fn(controller.signal).finally(() => {
clearTimeout(timeoutId);
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1 @@
sample text file

View File

@@ -0,0 +1,376 @@
#!/bin/bash
# Federation Integration Test Runner
# This script builds Rocket.Chat locally and starts the federation services,
# then waits for Rocket.Chat to be ready before running the end-to-end tests.
set -e # Exit on any error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Script moved under tests/scripts; package root is two levels up from script dir
PACKAGE_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")"
DOCKER_COMPOSE_FILE="$PACKAGE_ROOT/docker-compose.test.yml"
MAX_WAIT_TIME=240 # 4 minutes
CHECK_INTERVAL=5 # Check every 5 seconds
# Build configuration
# Use a temporary directory outside the repo to avoid symlink traversal issues during Meteor build
BUILD_DIR="$(mktemp -d "${FEDERATION_TEST_TMPDIR:-/tmp}/rc-federation-build-XXXXXX")"
ROCKETCHAT_ROOT="$(cd "$PACKAGE_ROOT/../../.." && pwd)" # Go up to project root
# Parse command line arguments
KEEP_RUNNING=false
INCLUDE_ELEMENT=false
USE_PREBUILT_IMAGE=false
PREBUILT_IMAGE=""
INTERRUPTED=false
NO_TEST=false
CI=false
LOGS=false
START_CONTAINERS=true
while [[ $# -gt 0 ]]; do
case $1 in
--start-containers-only)
NO_TEST=true
KEEP_RUNNING=true
shift
;;
--ci)
CI=true
KEEP_RUNNING=true
START_CONTAINERS=false
shift
;;
--logs)
LOGS=true
NO_TEST=true
START_CONTAINERS=false
shift
;;
--keep-running)
KEEP_RUNNING=true
shift
;;
--element)
INCLUDE_ELEMENT=true
shift
;;
--no-test)
NO_TEST=true
shift
;;
--image)
USE_PREBUILT_IMAGE=true
# If no IMAGE value is provided (or next token is another flag), default to latest
if [[ -z "${2:-}" || "$2" == -* ]]; then
PREBUILT_IMAGE="rocketchat/rocket.chat:latest"
shift 1
else
PREBUILT_IMAGE="$2"
shift 2
fi
;;
--help|-h)
echo "Usage: $0 [OPTIONS]"
echo "Options:"
echo " --start-containers-only Start containers and skip running tests"
echo " --ci Run tests in CI mode (keep containers running, no cleanup)"
echo " --logs Show logs of rc1 and hs1 containers"
echo " --keep-running Keep Docker containers running after tests complete"
echo " --element Include Element web client in the test environment"
echo " --no-test Start containers and skip running tests"
echo " --image [IMAGE] Use a pre-built Docker image instead of building locally"
echo " --help, -h Show this help message"
echo ""
echo "By default, builds Rocket.Chat locally and runs the 'test' profile"
echo "Use --image to test against a pre-built image (e.g., --image rocketchat/rocket.chat:latest)"
echo "If --image is provided without a value, defaults to rocketchat/rocket.chat:latest"
echo "Use --element to run all services including Element web client"
echo "Use --no-test to start containers and skip running tests"
exit 0
;;
*)
echo "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
# Logging functions
log_info() {
echo -e "${BLUE}ℹ️ [$(date '+%Y-%m-%d %H:%M:%S')] $1${NC}"
}
log_success() {
echo -e "${GREEN}✅ [$(date '+%Y-%m-%d %H:%M:%S')] $1${NC}"
}
log_warning() {
echo -e "${YELLOW}⚠️ [$(date '+%Y-%m-%d %H:%M:%S')] $1${NC}"
}
log_error() {
echo -e "${RED}❌ [$(date '+%Y-%m-%d %H:%M:%S')] $1${NC}"
}
docker_logs() {
echo ""
echo "ROCKET.CHAT (rc1) LOGS:"
echo "----------------------------------------"
docker compose -f "$DOCKER_COMPOSE_FILE" --profile "$COMPOSE_PROFILE" logs rc1
echo ""
echo "SYNAPSE (hs1) LOGS:"
echo "----------------------------------------"
docker compose -f "$DOCKER_COMPOSE_FILE" --profile "$COMPOSE_PROFILE" logs hs1
echo ""
echo "=========================================="
}
# Cleanup function
cleanup() {
if [ "$CI" = true ]; then
# Exit with the test result code
if [ -n "${TEST_EXIT_CODE:-}" ]; then
exit $TEST_EXIT_CODE
fi
fi
# Show container logs if tests failed
if [ -n "${TEST_EXIT_CODE:-}" ] && [ "$TEST_EXIT_CODE" -ne 0 ]; then
echo ""
echo "=========================================="
echo "CONTAINER LOGS (Test Failed)"
echo "=========================================="
docker_logs
fi
if [ "$KEEP_RUNNING" = true ]; then
log_info "Keeping Docker containers running (--keep-running flag set)"
log_info "Services are available at:"
log_info " - Rocket.Chat: https://rc1"
log_info " - Synapse: https://hs1"
log_info " - MongoDB: localhost:27017"
if [ "$INCLUDE_ELEMENT" = true ]; then
log_info " - Element: https://element"
fi
log_info "To stop containers manually, run: docker compose -f \"$DOCKER_COMPOSE_FILE\" --profile \"$COMPOSE_PROFILE\" down -v"
else
log_info "Cleaning up services..."
docker compose -f "$DOCKER_COMPOSE_FILE" --profile "$COMPOSE_PROFILE" down -v 2>/dev/null || true
log_success "Cleanup completed"
fi
# Remove temporary build directory if it exists
if [ -n "${BUILD_DIR:-}" ] && [ -d "$BUILD_DIR" ]; then
rm -rf "$BUILD_DIR" || true
fi
# Exit with the test result code
if [ -n "${TEST_EXIT_CODE:-}" ]; then
exit $TEST_EXIT_CODE
fi
}
# Setup signal handlers for cleanup
trap cleanup EXIT TERM
# Handle interrupt signal (Ctrl+C) immediately
trap 'INTERRUPTED=true; log_info "Received interrupt signal (Ctrl+C), stopping..."; cleanup; exit 130' INT
# Check if docker-compose.test.yml exists
if [ ! -f "$DOCKER_COMPOSE_FILE" ]; then
log_error "docker-compose.test.yml not found at $DOCKER_COMPOSE_FILE"
exit 1
fi
if [ "$INCLUDE_ELEMENT" = true ]; then
COMPOSE_PROFILE="element"
else
COMPOSE_PROFILE="test"
fi
if [ "$START_CONTAINERS" = true ]; then
# Build Rocket.Chat locally if not using pre-built image
if [ "$USE_PREBUILT_IMAGE" = false ]; then
log_info "🚀 Building Rocket.Chat locally..."
log_info "====================================="
# Clean up any existing build
log_info "Cleaning up previous build..."
rm -rf "$BUILD_DIR"
# Build the project
log_info "Building packages from project root..."
cd "$ROCKETCHAT_ROOT"
yarn build
# Build the Meteor bundle (must be run from the meteor directory)
log_info "Building Meteor bundle..."
cd "$ROCKETCHAT_ROOT/apps/meteor"
METEOR_DISABLE_OPTIMISTIC_CACHING=1 meteor build --server-only --directory "$BUILD_DIR"
log_success "Build completed!"
else
log_info "🚀 Using pre-built image: $PREBUILT_IMAGE"
log_info "====================================="
fi
log_info "🚀 Starting Federation Integration Tests"
log_info "====================================="
BUILD_PARAM=""
# Set environment variables for Docker Compose
if [ "$USE_PREBUILT_IMAGE" = true ]; then
export ROCKETCHAT_IMAGE="$PREBUILT_IMAGE"
log_info "Using pre-built image: $PREBUILT_IMAGE"
else
export ROCKETCHAT_BUILD_CONTEXT="$BUILD_DIR"
export ROCKETCHAT_DOCKERFILE="$ROCKETCHAT_ROOT/apps/meteor/.docker/Dockerfile.alpine"
BUILD_PARAM="--build"
log_info "Building from local context: $BUILD_DIR"
fi
# Start services
if [ "$INCLUDE_ELEMENT" = true ]; then
log_info "Starting all federation services including Element web client..."
else
log_info "Starting federation services (test profile only)..."
fi
docker compose -f "$DOCKER_COMPOSE_FILE" --profile "$COMPOSE_PROFILE" up -d $BUILD_PARAM
# Wait for rc1 container to be running
log_info "Waiting for rc1 container to start..."
timeout=60
while [ $timeout -gt 0 ] && [ "$INTERRUPTED" = false ]; do
if docker compose -f "$DOCKER_COMPOSE_FILE" ps rc1 --filter "status=running" | grep -q "rc1"; then
log_success "rc1 container is running"
break
fi
sleep 2
timeout=$((timeout - 2))
done
if [ "$INTERRUPTED" = true ]; then
log_info "Container startup interrupted by user"
exit 130
fi
if [ $timeout -le 0 ]; then
log_error "rc1 container failed to start within 60 seconds"
exit 1
fi
# Wait for both Rocket.Chat and Synapse to be ready
log_info "Waiting for Rocket.Chat and Synapse servers to be ready..."
# Function to wait for a service to be ready
wait_for_service() {
local url=$1
local name=$2
local host=$3
local elapsed=0
local ca_cert="${CA_CERT:-$PACKAGE_ROOT/docker-compose/traefik/certs/ca/rootCA.crt}"
# Derive host/port from URL when not explicitly provided
local host_with_port="${url#*://}"
host_with_port="${host_with_port%%/*}"
if [ -z "$host" ]; then
host="${host_with_port%%:*}"
fi
local port
if [[ "$host_with_port" == *:* ]]; then
port="${host_with_port##*:}"
else
if [[ "$url" == https://* ]]; then
port=443
else
port=80
fi
fi
log_info "Checking $name at $url (host $host -> 127.0.0.1:$port)..."
while [ $elapsed -lt $MAX_WAIT_TIME ] && [ "$INTERRUPTED" = false ]; do
# Capture curl output and error for debugging
curl_output=$(curl -fsS --cacert "$ca_cert" --resolve "${host}:${port}:127.0.0.1" "$url" 2>&1)
curl_exit_code=$?
if [ $curl_exit_code -eq 0 ]; then
log_success "$name is ready!"
return 0
fi
log_info "$name not ready yet, waiting... (${elapsed}s/${MAX_WAIT_TIME}s)"
log_info "Curl error: $curl_output"
sleep $CHECK_INTERVAL
elapsed=$((elapsed + CHECK_INTERVAL))
done
if [ "$INTERRUPTED" = true ]; then
log_info "Service check interrupted by user"
return 1
fi
log_error "$name failed to become ready within ${MAX_WAIT_TIME} seconds"
return 1
}
# Wait for Rocket.Chat
if ! wait_for_service "https://rc1/api/info" "Rocket.Chat" "rc1"; then
log_error "Last 50 lines of rc1 logs:"
docker compose -f "$DOCKER_COMPOSE_FILE" logs --tail 50 rc1
exit 1
fi
# Wait for Synapse
if ! wait_for_service "https://hs1/_matrix/client/versions" "Synapse" "hs1"; then
log_error "Last 50 lines of hs1 logs:"
docker compose -f "$DOCKER_COMPOSE_FILE" logs --tail 50 hs1
exit 1
fi
fi
# Run the end-to-end tests
if [ "$NO_TEST" = false ]; then
log_info "Running end-to-end tests..."
cd "$PACKAGE_ROOT"
set +e
IS_EE=true NODE_EXTRA_CA_CERTS=$(pwd)/docker-compose/traefik/certs/ca/rootCA.crt yarn test:federation
TEST_EXIT_CODE=$?
set -e
elif [ "$LOGS" = true ]; then
docker_logs
exit 0
else
log_info "No-test mode: skipping test execution"
log_info "Services are ready and running. You can now:"
log_info " - Access Rocket.Chat at: https://rc1"
log_info " - Access Synapse at: https://hs1"
log_info " - Access MongoDB at: localhost:27017"
if [ "$INCLUDE_ELEMENT" = true ]; then
log_info " - Access Element at: https://element"
fi
log_info ""
log_info "To run tests manually, execute: yarn testend-to-end"
log_info "To stop containers, use: docker compose -f $DOCKER_COMPOSE_FILE down"
TEST_EXIT_CODE=0
fi

View File

@@ -0,0 +1,20 @@
/**
* Global teardown for Jest federation tests.
*
* Ensures that any open connections or handles are properly closed
* to prevent Jest from hanging. This is particularly important for
* Matrix SDK connections and other long-lived resources that may
* prevent Jest from exiting cleanly.
*
* @returns Promise that resolves when cleanup is complete
*/
export default async function globalTeardown() {
// Force close any remaining open handles
// This is particularly important for Matrix SDK connections
if (global.gc) {
global.gc();
}
// Give a small delay to allow cleanup
await new Promise((resolve) => setTimeout(resolve, 1000));
}