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
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:
136
ee/packages/federation-matrix/tests/helper/config.ts
Normal file
136
ee/packages/federation-matrix/tests/helper/config.ts
Normal 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 };
|
||||
168
ee/packages/federation-matrix/tests/helper/ddp-listener.ts
Normal file
168
ee/packages/federation-matrix/tests/helper/ddp-listener.ts
Normal 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);
|
||||
}
|
||||
742
ee/packages/federation-matrix/tests/helper/synapse-client.ts
Normal file
742
ee/packages/federation-matrix/tests/helper/synapse-client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
ee/packages/federation-matrix/tests/helper/withTimeout.ts
Normal file
11
ee/packages/federation-matrix/tests/helper/withTimeout.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user