diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7769dab4a80d1..c3e81ea150324 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -279,11 +279,12 @@ jobs: fail-fast: false matrix: arch: [arm64, amd64] - service: [ - [authorization-service, queue-worker-service, ddp-streamer-service], - [account-service, presence-service, stream-hub-service, omnichannel-transcript-service], - [rocketchat] - ] + service: + [ + [authorization-service, queue-worker-service, ddp-streamer-service], + [account-service, presence-service, stream-hub-service, omnichannel-transcript-service], + [rocketchat], + ] type: # if running in a PR build with coverage - ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }} @@ -607,6 +608,54 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} REPORTER_JIRA_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_JIRA_ROCKETCHAT_API_KEY }} + test-federation-matrix: + name: 🔨 Test Federation Matrix + needs: [checks, build-gh-docker-publish, packages-build, release-versions] + runs-on: ubuntu-24.04-arm + + steps: + - uses: actions/checkout@v5 + + - name: Setup NodeJS + uses: ./.github/actions/setup-node + with: + node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + cache-modules: true + install: true + + - uses: rharkor/caching-for-turbo@v1.8 + + - name: Restore turbo build + uses: actions/download-artifact@v6 + continue-on-error: true + with: + name: turbo-build + path: .turbo/cache + + - name: Build packages + run: yarn build + + - name: Login to GitHub Container Registry + if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') && github.actor != 'dependabot[bot]' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + + - name: Configure /etc/hosts for federation services + run: | + sudo -- sh -c "echo '127.0.0.1 hs1' >> /etc/hosts" + sudo -- sh -c "echo '127.0.0.1 rc1' >> /etc/hosts" + + - name: Run federation integration tests with pre-built image + working-directory: ./ee/packages/federation-matrix + env: + ROCKETCHAT_IMAGE: ghcr.io/${{ needs.release-versions.outputs.lowercase-repo }}/rocket.chat:${{ needs.release-versions.outputs.gh-docker-tag }} + ENTERPRISE_LICENSE_RC1: ${{ secrets.ENTERPRISE_LICENSE_RC1 }} + run: yarn test:integration --image "${ROCKETCHAT_IMAGE}" + report-coverage: name: 📊 Report Coverage runs-on: ubuntu-24.04 @@ -662,7 +711,7 @@ jobs: tests-done: name: ✅ Tests Done runs-on: ubuntu-24.04-arm - needs: [checks, test-unit, test-api, test-ui, test-api-ee, test-ui-ee, test-ui-ee-watcher] + needs: [checks, test-unit, test-api, test-ui, test-api-ee, test-ui-ee, test-ui-ee-watcher, test-federation-matrix] if: always() steps: - name: Test finish aggregation @@ -695,6 +744,10 @@ jobs: exit 1 fi + if [[ '${{ needs.test-federation-matrix.result }}' != 'success' ]]; then + exit 1 + fi + echo finished deploy: diff --git a/.gitignore b/.gitignore index 81bca21648cfc..8ee032f2d8c32 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ storybook-static **/.vim/ data/ +!**/tests/data/ registration.yaml storybook-static diff --git a/apps/meteor/tests/data/messages.helper.ts b/apps/meteor/tests/data/messages.helper.ts new file mode 100644 index 0000000000000..9a8160ea361b0 --- /dev/null +++ b/apps/meteor/tests/data/messages.helper.ts @@ -0,0 +1,52 @@ +import type { IRoom } from '@rocket.chat/core-typings'; + +import { credentials, methodCall, request } from './api-data'; +import type { IRequestConfig } from './users.helper'; + +type SendMessageParams = { + rid: IRoom['_id']; + msg: string; + config?: IRequestConfig; +}; + +/** + * Sends a text message to a room using the method.call/sendMessage DDP endpoint. + * + * This helper function allows sending messages to rooms (channels, groups, DMs) + * for federation testing scenarios using the DDP method format. It supports + * custom request configurations for cross-domain federation testing. + * + * @param rid - The unique identifier of the room + * @param msg - The message text to send + * @param config - Optional request configuration for custom domains + * @returns Promise resolving to the API response + */ +export const sendMessage = ({ rid, msg, config }: SendMessageParams) => { + if (!rid) { + throw new Error('"rid" is required in "sendMessage" test helper'); + } + if (!msg) { + throw new Error('"msg" is required in "sendMessage" test helper'); + } + + const requestInstance = config?.request || request; + const credentialsInstance = config?.credentials || credentials; + + return requestInstance + .post(methodCall('sendMessage')) + .set(credentialsInstance) + .send({ + message: JSON.stringify({ + method: 'sendMessage', + params: [ + { + _id: `${Date.now()}-${Math.random()}`, + rid, + msg, + }, + ], + id: `${Date.now()}-${Math.random()}`, + msg: 'method', + }), + }); +}; diff --git a/apps/meteor/tests/data/rooms.helper.ts b/apps/meteor/tests/data/rooms.helper.ts index 975803343ba3f..958318bc3c55a 100644 --- a/apps/meteor/tests/data/rooms.helper.ts +++ b/apps/meteor/tests/data/rooms.helper.ts @@ -1,5 +1,6 @@ import type { Credentials } from '@rocket.chat/api-client'; -import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; +import type { IRoom, ISubscription, IUser, IMessage } from '@rocket.chat/core-typings'; +import type { Endpoints } from '@rocket.chat/rest-typings'; import { api, credentials, methodCall, request } from './api-data'; import type { IRequestConfig } from './users.helper'; @@ -161,3 +162,265 @@ export const addUserToRoom = ({ }), }); }; + +/** + * Adds users to a room using the /invite slash command via method.call. + * + * Executes the /invite slash command using the DDP method call to add users to a room. + * This simulates the user experience of using slash commands in the UI. + * Supports both local and federated users, with proper error handling for federation restrictions. + * + * @param usernames - Array of usernames to add to the room + * @param rid - The unique identifier of the room + * @param config - Optional request configuration for custom domains + * @returns Promise resolving to the method call response + * @note The slash command expects parameters: { cmd: string, params: string, msg: IMessage, triggerId: string } + */ +export const addUserToRoomSlashCommand = ({ + usernames, + rid, + config, +}: { + usernames: string[]; + rid: IRoom['_id']; + config?: IRequestConfig; +}) => { + if (!usernames || usernames.length === 0) { + throw new Error('"usernames" is required in "addUserToRoomSlashCommand" test helper'); + } + if (!rid) { + throw new Error('"rid" is required in "addUserToRoomSlashCommand" test helper'); + } + + const requestInstance = config?.request || request; + const credentialsInstance = config?.credentials || credentials; + + return requestInstance + .post(methodCall('slashCommand')) + .set(credentialsInstance) + .send({ + message: JSON.stringify({ + method: 'slashCommand', + params: [ + { + cmd: 'invite', + params: usernames.join(' '), + msg: { + rid, + _id: `test-${Date.now()}`, + }, + triggerId: `test-trigger-${Date.now()}`, + }, + ], + id: 'id', + msg: 'method', + }), + }); +}; + +/** + * Retrieves detailed information about a room. + * + * Fetches comprehensive room metadata including federation status, + * member counts, and other room properties needed for federation testing. + * + * @param roomId - The unique identifier of the room + * @param config - Optional request configuration for custom domains + * @returns Promise resolving to room information response + */ +export const getRoomInfo = (roomId: IRoom['_id'], config?: IRequestConfig) => { + const requestInstance = config?.request || request; + const credentialsInstance = config?.credentials || credentials; + + return new Promise>((resolve) => { + void requestInstance + .get(api('rooms.info')) + .set(credentialsInstance) + .query({ + roomId, + }) + .end((_err: any, req: any) => { + resolve(req.body); + }); + }); +}; + +/** + * Retrieves room members ordered by their role hierarchy. + * + * Gets the complete list of room members with their roles and permissions, + * ordered by importance. Essential for verifying federation member synchronization + * and role assignments across different Rocket.Chat instances. + * + * @param roomId - The unique identifier of the room + * @param config - Optional request configuration for custom domains + * @returns Promise resolving to ordered member list response + */ +export const getRoomMembers = (roomId: IRoom['_id'], config?: IRequestConfig) => { + const requestInstance = config?.request || request; + const credentialsInstance = config?.credentials || credentials; + + return new Promise>((resolve) => { + void requestInstance + .get(api('rooms.membersOrderedByRole')) + .set(credentialsInstance) + .query({ + roomId, + }) + .end((_err: any, req: any) => { + resolve(req.body); + }); + }); +}; + +/** + * Finds a specific room member with configurable retry logic. + * + * Searches for a member in a room by username, with retry logic to handle + * eventual consistency in federated systems. This is crucial for federation + * testing where member synchronization may take time to propagate. + * + * @param roomId - The unique identifier of the room to search + * @param username - The username 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) + * @param config - Optional request configuration for custom domains + * @returns Promise resolving to the user object if found, null otherwise + */ +export const findRoomMember = async ( + roomId: IRoom['_id'], + username: string, + options: { maxRetries?: number; delay?: number; initialDelay?: number } = {}, + config?: IRequestConfig, +): Promise => { + const { maxRetries = 3, delay = 1000, initialDelay = 0 } = options; + + if (initialDelay > 0) { + await new Promise((resolve) => setTimeout(resolve, initialDelay)); + } + + // eslint-disable-next-line no-await-in-loop + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + // eslint-disable-next-line no-await-in-loop + const membersResponse = await getRoomMembers(roomId, config); + const member = membersResponse.members.find((member: IUser) => member.username === username); + + if (member) { + return member; + } + + if (attempt < maxRetries) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } catch (error) { + console.warn(`Attempt ${attempt} to find room member failed:`, error); + + if (attempt < maxRetries) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + + return null; +}; + +/** + * Retrieves the message history for a group/private room. + * + * Fetches the complete message history including system messages, + * user messages, and federation events. Essential for verifying + * message synchronization and system message generation in federated rooms. + * + * @param roomId - The unique identifier of the room + * @param config - Optional request configuration for custom domains + * @returns Promise resolving to message history response + */ +export const getGroupHistory = (roomId: IRoom['_id'], config?: IRequestConfig) => { + const requestInstance = config?.request || request; + const credentialsInstance = config?.credentials || credentials; + + return new Promise>((resolve) => { + void requestInstance + .get(api('groups.history')) + .set(credentialsInstance) + .query({ + roomId, + }) + .end((_err: any, req: any) => { + resolve(req.body); + }); + }); +}; + +/** + * Loads message history for a room using the loadHistory method call. + * + * Fetches message history via the DDP method call endpoint, which returns + * messages with markdown parsing metadata (md attribute). This is useful + * for testing message rendering and markdown parsing, including emoji handling. + * + * @param rid - The unique identifier of the room + * @param config - Optional request configuration for custom domains + * @param end - Optional end date to load messages before this timestamp + * @param limit - Optional limit for number of messages to return (default: 20) + * @param ls - Optional last seen timestamp for unread calculation + * @param showThreadMessages - Optional flag to include thread messages (default: true) + * @returns Promise resolving to message history with structure: { messages, firstUnread?, unreadNotLoaded? } + */ +export const loadHistory = async ( + rid: IRoom['_id'], + config?: IRequestConfig, + end?: Date, + limit?: number, + ls?: string | Date, + showThreadMessages?: boolean, +) => { + const requestInstance = config?.request || request; + const credentialsInstance = config?.credentials || credentials; + + const params: any[] = [rid]; + if (end !== undefined) { + params.push(end); + } + if (limit !== undefined) { + params.push(limit); + } + if (ls !== undefined) { + params.push(ls); + } + if (showThreadMessages !== undefined) { + params.push(showThreadMessages); + } + + const response = await requestInstance + .post(methodCall('loadHistory')) + .set(credentialsInstance) + .send({ + message: JSON.stringify({ + method: 'loadHistory', + params, + id: 'id', + msg: 'method', + }), + }); + + if (!response.body.success) { + throw new Error(`loadHistory failed: ${JSON.stringify(response.body)}`); + } + + const data = JSON.parse(response.body.message); + if (data.error) { + throw new Error(`loadHistory method error: ${JSON.stringify(data.error)}`); + } + + return data.result as { + messages: IMessage[]; + firstUnread?: IMessage; + unreadNotLoaded?: number; + }; +}; diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts index cf9a7c79e6b04..f73785534032d 100644 --- a/apps/meteor/tests/data/users.helper.ts +++ b/apps/meteor/tests/data/users.helper.ts @@ -67,8 +67,6 @@ export const createUser = ( .post(api('users.create')) .set(credentialsInstance) .send({ email, name: username, username, password, ...userData }) - // if we don't expect 200, there's never an error, even in the case of a failure result - .expect(200) .end((err: unknown, res: Response) => { if (err) { return reject(err); diff --git a/ee/packages/federation-matrix/.env.example b/ee/packages/federation-matrix/.env.example new file mode 100644 index 0000000000000..a143225b29fc3 --- /dev/null +++ b/ee/packages/federation-matrix/.env.example @@ -0,0 +1 @@ +ENTERPRISE_LICENSE_RC1= diff --git a/ee/packages/federation-matrix/README.md b/ee/packages/federation-matrix/README.md new file mode 100644 index 0000000000000..12e9e9eeab811 --- /dev/null +++ b/ee/packages/federation-matrix/README.md @@ -0,0 +1,76 @@ +# Federation Matrix + +Rocket.Chat's Matrix federation integration package for cross-platform communication. + +## Integration Tests + +### Setup + +Before running integration tests, add the following entries to your `/etc/hosts` file: + +``` +127.0.0.1 element +127.0.0.1 hs1 +127.0.0.1 rc1 +``` + +### How It Works + +The integration test script builds Rocket.Chat locally, starts federation services (Rocket.Chat, Synapse, MongoDB), waits for all services to be ready, then runs end-to-end tests. The script automatically handles cleanup unless you specify otherwise. + +### Available Flags + +- **No flags (default)**: Builds local code and runs tests +- `--image [IMAGE]`: Uses a pre-built Docker image instead of building locally (defaults to `rocketchat/rocket.chat:latest` if no image specified) +- `--keep-running`: Keeps containers running after tests complete for manual validation +- `--element`: Includes Element web client in the test environment +- `--no-test`: Starts containers and skips running tests (useful for manual testing or debugging) + +### Usage Examples + +**Basic local testing:** +```bash +yarn test:integration +``` + +**Test with pre-built image:** +```bash +yarn test:integration --image +``` + +**Test with specific pre-built image:** +```bash +yarn test:integration --image rocketchat/rocket.chat:latest +``` + +**Keep services running for manual inspection:** +```bash +yarn test:integration --keep-running +``` + +**Run with Element client:** +```bash +yarn test:integration --element +``` + +**Start containers only (skip tests):** +```bash +yarn test:integration --no-test +``` + +**Start containers with Element and keep them running (skip tests):** +```bash +yarn test:integration --keep-running --element --no-test +``` + +**Combine flags:** +```bash +yarn test:integration --image rocketchat/rocket.chat:latest --keep-running --element +``` + +### Service URLs (when using --keep-running or --no-test) + +- **Rocket.Chat**: https://rc1 +- **Synapse**: https://hs1 +- **MongoDB**: localhost:27017 +- **Element**: https://element (when using --element flag) diff --git a/ee/packages/federation-matrix/docker-compose.test.yml b/ee/packages/federation-matrix/docker-compose.test.yml new file mode 100644 index 0000000000000..06e0b30d8c696 --- /dev/null +++ b/ee/packages/federation-matrix/docker-compose.test.yml @@ -0,0 +1,216 @@ +networks: + hs1-net: + rc1-net: + element-net: + +services: + traefik: + image: traefik:v2.9 + container_name: traefik + profiles: + - test-local + - test-prebuilt + - element-local + - element-prebuilt + command: + - "--api.insecure=true" + # - "--log.level=DEBUG" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.web.address=:80" + - "--entrypoints.websecure.address=:443" + ports: + - "80:80" + - "443:443" + - "8080:8080" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./docker-compose/traefik/traefik.yml:/etc/traefik/traefik.yml:ro + - ./docker-compose/traefik/dynamic_conf.yml:/etc/traefik/dynamic_conf.yml:ro + - ./docker-compose/traefik/certs:/etc/traefik/certs:ro + # - ./traefik-logs:/logs # Mount a volume for logs + networks: + # Defines and isolate one network per service to prevent inter service communication which + # would not happen in real life. The name clashing between the host and service in the same + # network (like rc1 service provided as rc1 host) is causing a bug on home server address + # resolution which tries to communicate with the container directly some times and which does + # not provide SSL neither the correct exposed ports. + hs1-net: + aliases: [hs1, rc1, rc.host] + rc1-net: + aliases: [hs1, rc1, rc.host] + element-net: + aliases: [hs1, rc1, rc.host, element] + +# HomeServer 1 (synapse) + hs1: + image: matrixdotorg/synapse:latest + container_name: hs1 + profiles: + - test-local + - test-prebuilt + - element-local + - element-prebuilt + entrypoint: | + sh -c + "update-ca-certificates && + mkdir -p /data/media_store && + chown -R 991:991 /data && + /start.py & + until curl -sf http://localhost:8008/_matrix/client/versions; do + echo '=====> Waiting for Synapse...'; + sleep 2; + done; + echo ''; + echo '=====> Running register_new_matrix_user...'; + register_new_matrix_user -u admin -p admin --admin http://localhost:8008 -c /data/homeserver.yaml | sed 's/^/=======> /'; + register_new_matrix_user -u alice -p alice --admin http://localhost:8008 -c /data/homeserver.yaml | sed 's/^/=======> /'; + register_new_matrix_user -u bob -p bob --admin http://localhost:8008 -c /data/homeserver.yaml | sed 's/^/=======> /'; + register_new_matrix_user -u cleiton -p cleiton --admin http://localhost:8008 -c /data/homeserver.yaml | sed 's/^/=======> /'; + echo '=====> Finished register_new_matrix_user.'; + wait" + volumes: + - ./docker-compose/hs1/homeserver.yaml:/data/homeserver.yaml + - ./docker-compose/hs1/hs1.log.config:/data/hs1.log.config + - ./docker-compose/hs1/hs1.signing.key:/data/hs1.signing.key + - ./docker-compose/traefik/certs/ca:/usr/local/share/ca-certificates + networks: + - hs1-net + labels: + - "traefik.enable=true" + - "traefik.http.routers.hs1.rule=Host(`hs1`)" + - "traefik.http.routers.hs1.entrypoints=websecure" + - "traefik.http.routers.hs1.tls=true" + - "traefik.http.services.hs1.loadbalancer.server.port=8008" + +# Rocket.Chat rc1 (local build) + rc1-local: + build: + context: ${ROCKETCHAT_BUILD_CONTEXT:-./test/dist} + dockerfile: ${ROCKETCHAT_DOCKERFILE:-../../../apps/meteor/.docker/Dockerfile.alpine} + image: rocket.chat:local-test + container_name: rc1 + profiles: + - test-local + - element-local + environment: + ROOT_URL: https://rc1 + PORT: 3000 + MONGO_URL: mongodb://mongo:27017/rc1?replicaSet=rs0 + NODE_EXTRA_CA_CERTS: /usr/local/share/ca-certificates/rootCA.pem + LOG_LEVEL: debug + ROCKETCHAT_LICENSE: ${ENTERPRISE_LICENSE_RC1} + OVERWRITE_SETTING_Show_Setup_Wizard: completed + OVERWRITE_SETTING_Federation_Service_Enabled: true + OVERWRITE_SETTING_Federation_Service_Domain: rc1 + OVERWRITE_SETTING_Cloud_Workspace_Client_Id: temp_id + OVERWRITE_SETTING_Cloud_Workspace_Client_Secret: temp_secret + ADMIN_USERNAME: admin + ADMIN_PASS: admin + ADMIN_EMAIL: admin@admin.com + TEST_MODE: true + volumes: + - ./docker-compose/traefik/certs/ca/rootCA.crt:/usr/local/share/ca-certificates/rootCA.pem + networks: + - rc1-net + depends_on: + - mongo + labels: + - "traefik.enable=true" + - "traefik.http.routers.rc1.rule=Host(`rc1`)" + - "traefik.http.routers.rc1.entrypoints=websecure" + - "traefik.http.routers.rc1.tls=true" + - "traefik.http.services.rc1.loadbalancer.server.port=3000" + # HTTPS Redirect + - "traefik.http.middlewares.rc1.redirectscheme.scheme=https" + - "traefik.http.routers.rc1-http.rule=Host(`rc1`)" + - "traefik.http.routers.rc1-http.middlewares=rc1" + +# Rocket.Chat rc1 (pre-built image) + rc1-prebuilt: + image: ${ROCKETCHAT_IMAGE:-rocketchat/rocket.chat:latest} + container_name: rc1 + profiles: + - test-prebuilt + - element-prebuilt + environment: + ROOT_URL: https://rc1 + PORT: 3000 + MONGO_URL: mongodb://mongo:27017/rc1?replicaSet=rs0 + NODE_EXTRA_CA_CERTS: /usr/local/share/ca-certificates/rootCA.pem + LOG_LEVEL: debug + ROCKETCHAT_LICENSE: ${ENTERPRISE_LICENSE_RC1} + OVERWRITE_SETTING_Show_Setup_Wizard: completed + OVERWRITE_SETTING_Federation_Service_Enabled: true + OVERWRITE_SETTING_Federation_Service_Domain: rc1 + OVERWRITE_SETTING_Cloud_Workspace_Client_Id: temp_id + OVERWRITE_SETTING_Cloud_Workspace_Client_Secret: temp_secret + ADMIN_USERNAME: admin + ADMIN_PASS: admin + ADMIN_EMAIL: admin@admin.com + TEST_MODE: true + volumes: + - ./docker-compose/traefik/certs/ca/rootCA.crt:/usr/local/share/ca-certificates/rootCA.pem + networks: + - rc1-net + depends_on: + - mongo + labels: + - "traefik.enable=true" + - "traefik.http.routers.rc1.rule=Host(`rc1`)" + - "traefik.http.routers.rc1.entrypoints=websecure" + - "traefik.http.routers.rc1.tls=true" + - "traefik.http.services.rc1.loadbalancer.server.port=3000" + # HTTPS Redirect + - "traefik.http.middlewares.rc1.redirectscheme.scheme=https" + - "traefik.http.routers.rc1-http.rule=Host(`rc1`)" + - "traefik.http.routers.rc1-http.middlewares=rc1" + + mongo: + image: mongo:8.0 + container_name: mongo + profiles: + - test-local + - test-prebuilt + - element-local + - element-prebuilt + restart: on-failure + ports: + - "27017:27017" + entrypoint: | + bash -c + "mongod --replSet rs0 --bind_ip_all & + sleep 2; + until mongosh --eval \"db.adminCommand('ping')\"; do + echo '=====> Waiting for Mongo...'; + sleep 1; + done; + echo '=====> Initiating ReplSet...'; + mongosh --eval \"rs.initiate({_id: 'rs0', members: [{ _id: 0, host: 'mongo:27017' }]})\"; + echo '=====> Initiating ReplSet done...'; + wait" + networks: + - rc1-net + + element: + image: vectorim/element-web + container_name: element + profiles: + - element-local + - element-prebuilt + # ports: + # - "8080:80" + volumes: + - ./docker-compose/element/config.json:/app/config.json + networks: + - element-net + labels: + - "traefik.enable=true" + - "traefik.http.routers.element.rule=Host(`element`)" + - "traefik.http.routers.element.entrypoints=websecure" + - "traefik.http.routers.element.tls=true" + - "traefik.http.services.element.loadbalancer.server.port=80" + # HTTPS Redirect + - "traefik.http.middlewares.element.redirectscheme.scheme=https" + - "traefik.http.routers.element-http.rule=Host(`element`)" + - "traefik.http.routers.element-http.middlewares=element" diff --git a/ee/packages/federation-matrix/docker-compose/element/config.json b/ee/packages/federation-matrix/docker-compose/element/config.json new file mode 100644 index 0000000000000..5f8f6639df565 --- /dev/null +++ b/ee/packages/federation-matrix/docker-compose/element/config.json @@ -0,0 +1,19 @@ +{ + "default_server_config": { + "m.homeserver": { + "base_url": "https://hs1", + "server_name": "hs1" + }, + "m.identity_server": { + "base_url": "https://vector.im" + } + }, + "disable_password_rules": true, + "disable_custom_urls": false, + "disable_guests": false, + "brand": "Element", + "enable_presence_by_hs_url": { + "https://hs1": false, + "https://rc1": false + } +} \ No newline at end of file diff --git a/ee/packages/federation-matrix/docker-compose/hs1/homeserver.yaml b/ee/packages/federation-matrix/docker-compose/hs1/homeserver.yaml new file mode 100644 index 0000000000000..decb4ee98cd65 --- /dev/null +++ b/ee/packages/federation-matrix/docker-compose/hs1/homeserver.yaml @@ -0,0 +1,149 @@ +# Configuration file for Synapse. +server_name: "hs1" +pid_file: /data/hs1.pid + +listeners: + - port: 8008 + tls: false + type: http + x_forwarded: true + resources: + - names: [client, federation] + +use_x_forwarded_for: true +serve_server_wellknown: true + +database: + name: sqlite3 + args: + database: /data/hs1.db + +log_config: "/data/hs1.log.config" +media_store_path: /data/media_store + +registration_shared_secret: "3l5H.Y5urc5@gKwYMe2^abk@nf.U_M6iyMgP,j&OL6pcSGrUQE" +report_stats: false +macaroon_secret_key: "3II&OLx=,6RcC&E~iksWi:tUU4J.~reK:MdU&JAPhlaGIL*+IA" +form_secret: "B2EuUMKfg&.BsU;#DZ9;E,baB^wc=lV--F_PwkRLZvk=LHk+U4" +signing_key_path: "/data/hs1.signing.key" + +trusted_key_servers: [] + # - server_name: "matrix.org" + # accept_keys_insecurely: true + +federation_client_minimum_tls_version: 1.2 +federation_verify_certificates: false +suppress_key_server_warning: true + +enable_registration: true +enable_registration_without_verification: true +allow_public_rooms_over_federation: true + +password_config: + enabled: true + localdb_enabled: true + policy: + minimum_length: 1 + require_lowercase: false + require_uppercase: false + require_digit: false + require_symbol: false + +# Optionally, add your Docker network to the whitelist +ip_range_whitelist: + - '0.0.0.0/0' + +# -------------------------------------------------------------------- +# Development rate limits (set very high) +# -------------------------------------------------------------------- + +# Messaging +rc_message: + per_second: 10000 + burst_count: 10000 + +# Registration +rc_registration: + per_second: 10000 + burst_count: 10000 + +# Registration token validity checks +rc_registration_token_validity: + per_second: 10000 + burst_count: 10000 + +# Login (IP, account, and failed-attempt buckets) +rc_login: + address: + per_second: 10000 + burst_count: 10000 + account: + per_second: 10000 + burst_count: 10000 + failed_attempts: + per_second: 10000 + burst_count: 10000 + +# Joins +rc_joins: + local: + per_second: 10000 + burst_count: 10000 + remote: + per_second: 10000 + burst_count: 10000 + +# Limit recent joins per room +rc_joins_per_room: + per_second: 10000 + burst_count: 10000 + +# Invites +rc_invites: + per_room: + per_second: 10000 + burst_count: 10000 + per_user: + per_second: 10000 + burst_count: 10000 + +# 3PID validation (email/phone) +rc_3pid_validation: + per_second: 10000 + burst_count: 10000 + +# To-device and key requests +rc_to_device: + per_second: 10000 + burst_count: 10000 +rc_key_requests: + per_second: 10000 + burst_count: 10000 + +# Presence / typing / receipts / read markers +rc_presence: + per_second: 10000 + burst_count: 10000 +rc_typing: + per_second: 10000 + burst_count: 10000 +rc_read_receipts: + per_second: 10000 + burst_count: 10000 +rc_read_markers: + per_second: 10000 + burst_count: 10000 + +# Room-admin redactions +rc_admin_redaction: + per_second: 10000 + burst_count: 10000 + +# Federation (uses *different* keys, not per_second/burst_count) +rc_federation: + window_size: 10000 # ms + sleep_limit: 10000 + sleep_delay: 0 # ms + reject_limit: 10000 + concurrent: 10000 +# vim:ft=yaml \ No newline at end of file diff --git a/ee/packages/federation-matrix/docker-compose/hs1/hs1.log.config b/ee/packages/federation-matrix/docker-compose/hs1/hs1.log.config new file mode 100644 index 0000000000000..8d4c737bb1986 --- /dev/null +++ b/ee/packages/federation-matrix/docker-compose/hs1/hs1.log.config @@ -0,0 +1,39 @@ +version: 1 + +formatters: + precise: + + format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' + + +handlers: + + + console: + class: logging.StreamHandler + formatter: precise + +loggers: + # This is just here so we can leave `loggers` in the config regardless of whether + # we configure other loggers below (avoid empty yaml dict error). + _placeholder: + level: "INFO" + + + + synapse.storage.SQL: + # beware: increasing this to DEBUG will make synapse log sensitive + # information such as access tokens. + level: INFO + + + + +root: + level: INFO + + + handlers: [console] + + +disable_existing_loggers: false \ No newline at end of file diff --git a/ee/packages/federation-matrix/docker-compose/hs1/hs1.signing.key b/ee/packages/federation-matrix/docker-compose/hs1/hs1.signing.key new file mode 100644 index 0000000000000..f14f2e79a890c --- /dev/null +++ b/ee/packages/federation-matrix/docker-compose/hs1/hs1.signing.key @@ -0,0 +1 @@ +ed25519 a_HDhg WntaJ4JP5WbZZjDShjeuwqCybQ5huaZAiowji7tnIEw diff --git a/ee/packages/federation-matrix/docker-compose/traefik/certs/.gitignore b/ee/packages/federation-matrix/docker-compose/traefik/certs/.gitignore new file mode 100644 index 0000000000000..58348aaa0e963 --- /dev/null +++ b/ee/packages/federation-matrix/docker-compose/traefik/certs/.gitignore @@ -0,0 +1,18 @@ +# Allow only the certificates needed for CI testing +# Override the root .gitignore rule for *.pem + +# Allow rc1 certificates +!rc1.pem +!rc1-key.pem + +# Allow hs1 certificates +!hs1.pem +!hs1-key.pem + +# Allow element certificates +!element.pem +!element-key.pem + +# Keep ignoring all other .pem files +*.pem + diff --git a/ee/packages/federation-matrix/docker-compose/traefik/certs/ca/rootCA.crt b/ee/packages/federation-matrix/docker-compose/traefik/certs/ca/rootCA.crt new file mode 100644 index 0000000000000..4a1c9af34b652 --- /dev/null +++ b/ee/packages/federation-matrix/docker-compose/traefik/certs/ca/rootCA.crt @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFLDCCA5SgAwIBAgIRAMEZ3b7sw0tDnhAcUN+76PkwDQYJKoZIhvcNAQELBQAw +ga0xHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTFBMD8GA1UECww4cm9j +a2V0Y2hhdEBSb2NrZXRDaGF0cy1NYWNCb29rLVByby0yLmxvY2FsIChSb2NrZXQu +Q2hhdCkxSDBGBgNVBAMMP21rY2VydCByb2NrZXRjaGF0QFJvY2tldENoYXRzLU1h +Y0Jvb2stUHJvLTIubG9jYWwgKFJvY2tldC5DaGF0KTAeFw0yNDExMzAxMTQyMDVa +Fw0zNDExMzAxMTQyMDVaMIGtMR4wHAYDVQQKExVta2NlcnQgZGV2ZWxvcG1lbnQg +Q0ExQTA/BgNVBAsMOHJvY2tldGNoYXRAUm9ja2V0Q2hhdHMtTWFjQm9vay1Qcm8t +Mi5sb2NhbCAoUm9ja2V0LkNoYXQpMUgwRgYDVQQDDD9ta2NlcnQgcm9ja2V0Y2hh +dEBSb2NrZXRDaGF0cy1NYWNCb29rLVByby0yLmxvY2FsIChSb2NrZXQuQ2hhdCkw +ggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQDUSzYDKSavpVKGEG0M2WQb +LShnhwqx+TZRlncn85RNJNkVooovw0x4Q104Rmo3xOS2fwaKC5fgX1T+KNvTFFVz +dyEhsGPYBSQLD27nQ1pgO5RDMsOC54dSoFLq6SbRZXpKBjiYGvB9sQ6mkSk+DWIX +EpacbgPsmJma8pm5C03SZI8HSlUYhSKYdjF6ph/6gBeRMUu5emnx3GRYS5GMGYZJ +XxUF2HbCYeGoubbc6UvLdtKyWmo5qL0gIVfoNTlJK/s61QzFDY9pA6ULoMBwocPh +KW/1RFODSPYyjwVgckXGil3jcWHVpwLVdZXsOWu+2sbZpCfkXNGrasDm6NBjKJK+ +uxM8+2zz/7yZX+SOh0bVK0St8R1Khl+jLC/xwGA4tVYT04S2f+1OWT+dqfCWHNjq +7GOcs1AQIvh8Yntx+1gwRIqcirGQCTtvzsF+zKWPmh1vip9fBkVL/6N4JK3XALiI +c6DImZHKG/6Ezw3fCilTV/MTnXeRazRyNozigJoQd5sCAwEAAaNFMEMwDgYDVR0P +AQH/BAQDAgIEMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFPMCCoABwDeC +i5pWH8Fm1EKCREu1MA0GCSqGSIb3DQEBCwUAA4IBgQCzSvcM8hPFFT0bzd9VYAoF +wKHDRJJfj3YgVY+ogfxiAvn8S/n2BBPHnPMLstGUTnNdd4Nr0QYot1H0H23QdS3m +gHbodVmp3+Ylc51RepCOqjT4nL6nCYcXr4DrDbIExIT+YyLRlrGyChEwn0AfdvFO +6+kfN6Jj0yT2uGTw0yz9x6Cnl0wJozhubcPEahc/lyhjxkYFA0YujNhS5ZPk33sH +jVJC2k3vSLD/wS6vYq+4A2yUB4KlVLVLUxEakzK5fEoAkyh2wnFAKa8uVcwU2Fz5 +mEJv4KmW8SpBTQYXILCZFXLO1ON9Ge7yYFquLMAo5QVrnwVm5EFgN2+Lnw6n8h+M +OhoPw4II46EEbMFN42aVV1jwIasIitO0eXG9XB+ZaPA64xcwOzRK2g65IhjPBrLi +MiugUT/fILCBkogBwctcpfkm6+hbVM1xLkkcUOgX6+nGSg8KvpRPWqx7bn2uoa5E +uT4+6tdAtAXFCdbIk8pMsfz3fFLjL5BBDtPfa+7mnyw= +-----END CERTIFICATE----- diff --git a/ee/packages/federation-matrix/docker-compose/traefik/certs/element-key.pem b/ee/packages/federation-matrix/docker-compose/traefik/certs/element-key.pem new file mode 100644 index 0000000000000..222af527f285e --- /dev/null +++ b/ee/packages/federation-matrix/docker-compose/traefik/certs/element-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCdF/nyYooyuxwi +7C1BZhSa3Q/ybollntgHEeyFEl43CEEanwFYVUSt5wnESCbGP0GwjWOs2YXv8ax1 +1HaLJoEjo7/a9x4Zzgmi+4W31nGSGar1FGnUGlA44/Nv9uawcHBMS1C8IPeZNdCQ +WD15J1xDS4Lt1EfIpmeMbfvtxsWIEWMfSgMUEI4z9xiUSbuWvQlPkN1ImJIGw4mg +DagQVF0IbicyGU0TJxjeR06DJKrL4b+tA9b4Oo7JFP1lCLAjj8dAk5ZJerq12qhh +djZwrs9RodVqr6jfT5Ut+ct+2d7mXALpZTbNzP3r1+CqIZFEOpoLw6L3gF86oTTO +8lUCSWTXAgMBAAECggEAJ8/ov13ryjmTQuJ6AaX+ONUncnhDB+b+tqP7PipOiEHm +RfngHGPXuP7hgcYLVZi4bCcZCLhD8nBcFayXsmI6vi6Es9iG32FRHeYCmHmXZMBu +jBf1ThSxGgnjKw/2nOaR/zPjLRppxYFW7w393lN0VXWwo9d1rctGWNeSzkreysG/ +ldp4dZZ4asV4BSuZj5EYVlOjKiGvAsn0juKERZPnYDXxCWhIDxME/Z5YY5lbfwkk +FK9tWU4cwEtHN+OMl3ouLTOKIAAYevwBeT99c4aIUxPZGdqjRexglfH3XcJhpLMk +NGQrEKwHhSMm+LU1bmhA+yIPqwv1d+a9WFpKXTrUoQKBgQDBDspAd51OB0sfQ2zJ +kwBKlqCUQro/iNVNZYRcTTxu3wma1i6NmRdbtdnQDPEDgmktYToe0AByHBwP9ibJ +vV/dm5moNiHVl3c6nzTZr8FxSg6fceE3l8ciG23tKBN76MW4Knvendq2JvKjQiCd +W+IxRQPntzjwBzGouLuuWK5oxwKBgQDQT4IFaV8a+njpqVdojlLnNOyAXD9BJQbs +DQzob+ev86Q/bP1z2oBg5jjnUciy6wIeRGWrwOeC2Cj6dG9PVSyijWIC3B4tnyy4 +Q0/LOIELc3NFi0CjTW0hFX9JU74Oqbn5rcgTUq/E1I96ed1wNOSOhIRommgSZkAS +AwObP6azcQKBgQCXDQhoKm6013YKjwm2KcNHmNUpS5BIF7q05OIMCg5nvTDZqg0g +kxC4m+9BHm8QzN+YNwNvilVe8ult/61XmUlBrfYqq7gLU4hhIKIMVLyo9EW+sS6C +/ck7wXRf40RjZbwVnX/vrVirvouH+zxjgrnWzOYCTdRJ0YckOO33usEzjwKBgQC+ +iUsLeiNUwO/iAlQCPdRUyRLeIgJ1qtGXDiTVYq5QQZHlteJqmty8RTidVTA3f0AT +wUoh/LOF+gQZenDp5qWKFbollYNBBYxZCrCs2IUonTQ90y6PcF22WjxwLNn1/Ycv +eqY8DnDZn/eQ9nD2llrMhSe7qigxVDecggdFdMYc8QKBgQCvHBRXcSw7UVuKGCw3 +/KeAmB+ZBF2xgF8Td+xq7MFROZaW2IU+TzpJkM0fFD5cn4ppTsNJfUSabObkW7dI +jqexSplF1yX9Yl9ydriw+E79Vfn25AOubDiuzTi3Lhpz2KnTyii04Slqwh42YN/k +8yuVbu5Yj39sXw+crwE+5W1YOQ== +-----END PRIVATE KEY----- diff --git a/ee/packages/federation-matrix/docker-compose/traefik/certs/element.pem b/ee/packages/federation-matrix/docker-compose/traefik/certs/element.pem new file mode 100644 index 0000000000000..0b6054fe9ac6e --- /dev/null +++ b/ee/packages/federation-matrix/docker-compose/traefik/certs/element.pem @@ -0,0 +1,27 @@ +-----BEGIN CERTIFICATE----- +MIIEhzCCAu+gAwIBAgIQYmNkAunze8euqZpKgVl22jANBgkqhkiG9w0BAQsFADCB +rTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMUEwPwYDVQQLDDhyb2Nr +ZXRjaGF0QFJvY2tldENoYXRzLU1hY0Jvb2stUHJvLTIubG9jYWwgKFJvY2tldC5D +aGF0KTFIMEYGA1UEAww/bWtjZXJ0IHJvY2tldGNoYXRAUm9ja2V0Q2hhdHMtTWFj +Qm9vay1Qcm8tMi5sb2NhbCAoUm9ja2V0LkNoYXQpMB4XDTI1MDgyOTEzNDIxNloX +DTI3MTEyOTEzNDIxNlowczEnMCUGA1UEChMebWtjZXJ0IGRldmVsb3BtZW50IGNl +cnRpZmljYXRlMUgwRgYDVQQLDD9yb2NrZXRjaGF0QFJvY2tldENoYXRzLU1hY0Jv +b2stUHJvLTIubG9jYWwgKFJvZHJpZ28gTmFzY2ltZW50bykwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQCdF/nyYooyuxwi7C1BZhSa3Q/ybollntgHEeyF +El43CEEanwFYVUSt5wnESCbGP0GwjWOs2YXv8ax11HaLJoEjo7/a9x4Zzgmi+4W3 +1nGSGar1FGnUGlA44/Nv9uawcHBMS1C8IPeZNdCQWD15J1xDS4Lt1EfIpmeMbfvt +xsWIEWMfSgMUEI4z9xiUSbuWvQlPkN1ImJIGw4mgDagQVF0IbicyGU0TJxjeR06D +JKrL4b+tA9b4Oo7JFP1lCLAjj8dAk5ZJerq12qhhdjZwrs9RodVqr6jfT5Ut+ct+ +2d7mXALpZTbNzP3r1+CqIZFEOpoLw6L3gF86oTTO8lUCSWTXAgMBAAGjXDBaMA4G +A1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAfBgNVHSMEGDAWgBTz +AgqAAcA3gouaVh/BZtRCgkRLtTASBgNVHREECzAJggdlbGVtZW50MA0GCSqGSIb3 +DQEBCwUAA4IBgQAO7pYTwdEMm1GV+/BJoYQ7tv9i/GwJm5FlACk8eAyZBWREy1wU +vaHcXB1Ssi4PbRSvGgQYOIq6CEzxA+Xa9nQ4yrvFi1iaS711Q3NS2ManS3oYUsNJ +G48XLF2WtxOWcRM6CZNXdii4o6SNUrH3TizP40WjovCQkTrSfe8b8wfnCjEetWU3 +JGLNmMOqLhgqzFL0IDRQi+LNvpKYKXRjSVaNurVUjSx/DS8IQBgM6q3Q5wmnjD29 +KCx4vJyIpd03BbuVdTP8/l9xIn/mntPCAD54Va+uUy1FyiVUiRIShwIIbFBA7+Ql +Y6hxcN2UjfFN/fet2xdJ31ICBO8jWxIXffsf0/lqLjnzumkCRUvMpq4WKBE9Zijt +6xALTbQB9lyasp1M3lr3rUWZ5bmt5imVRqqTTzbUyGiMQuFw5O8SHBBD2fcH26pS +mxYZ1bCY22Ukek1q6Tvq8BZmQxn/E6f957gPtlo1hZNpMr0XJYJGDPqKIA4p6IbQ +jQXJXsJ79isWppQ= +-----END CERTIFICATE----- diff --git a/ee/packages/federation-matrix/docker-compose/traefik/certs/hs1-key.pem b/ee/packages/federation-matrix/docker-compose/traefik/certs/hs1-key.pem new file mode 100644 index 0000000000000..c0b298652963d --- /dev/null +++ b/ee/packages/federation-matrix/docker-compose/traefik/certs/hs1-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCeBotj7IqU/7C3 +4ac1FTdt28z5T31/0htBrgYreLynEhmrKn0pac9WhmGTgXo7y1ep9nmQd6rKYC1V +aEhC3PBl3eC55z01G5wgYxo2aQUeJer/ZZr25iHeIXAy+GQ8egXa7VuyUZXvDFtA +/BauwV5gJGGbAb5bHVH8tZ/DPHMxqfqbUShk4IZGIR1Lu1VK9y4rvAOWhM3uPYOL +hfEkCDExkTtNRHNoTnCY9bt1K0zM6egcqdegBOrXw4tRKfAJ3qbUL/3c3sf4vSiW +MeOM+TmVaeJ3uuDxu8Q/ufELdglnbAJxAGhKtl/aE4Lvhrqxi6Pc/A4LrgQcBcHh +wDjz1KYXAgMBAAECggEBAJVfwFETHigbzfLzNXgC9yM8WvPrRMkXVTZvhlt/RGx0 +upjGkAsefqPeYMvq3x04aEQ7vMtnoqSr5w5NhcjWSdMwuaVgxMW3tIwUwYlfGjxW +QV1rsODjxm3Pk9xKWY2lFnXMWhoj7qNJPdUetV45YLlminDYZBSCkaZcFImbUZRG +CToU0zmzPqE5zmqDZ+2xbOUfp4NrI4q9G/BNxWzbgkMsnn0cly7akcbLBrV8Q7QK +cdwTmHd4ihs3SeQa6GNiCbutnv+ni8vNF+91ti4vqzvrYD2HzJjt0Ya/WFA7nBdZ +fAA+DiNg34qAGE2w4w/xCcYyNClwy1QyTxczDrwFlGECgYEAzw8S8hlMvKDKGIwz +EBBDfV3zXo2tC/JO94KUqQpO9d4dRhlbjr+sYxw5t6qkJye8Z7L8l9g7O5Bebdy1 +zGQ38sDqlDTncC8eMv1AnUzFp8nm/DzcAd6mvqbakV0lTX0MTjsMgYYsWNllsLK0 +f3mMRX95l1/eQLwGpzFr7aPkT+cCgYEAw2CDbuUcmUYYBWdmkqANbqZM3QIN/fAc +QdXd6f+PuJGjZkeUbNIMnPONCQZZXw9aCHmfNpGnJ+SnLPh1/RDEJHMc+E53xdya +Ba7FfRjKg1qT5Ta5wNv8oFEzP9zpuTzlaEY53KdkoRIPvoCBahTK/cCPWePlqXGr +FA3bpNyZ8lECgYEAtPZKq5Ya79xi5DNbyVU9dsrukRunOoKqKz1PqbEds1pNIV+2 +GjtAcVpQw0l6r9HVopfWlOrhUbxqGwBDTv/jueCK476c6vnzHcMifpeDQ5J8ssSJ +z4SFHKj86wCKQn/gilqnImheR8SwUE7O234ibvbrELYzq0XpVqQl3IpZVs8CgYEA +gEtxIzHpvLBtd6b1kRTunRkw4fPGcljohUbF6TF7E8z2ymP4kBjCVZIMq79yklyH +V4ddyDyO0kBwkrQ47kvDMNgyTs9gERqSPWcNod7UpLqm4V41TfJnCnMnvyj2hT/6 +uKVcu4tkJeyxT/wcfydWQJjgyTtAhSryF0IeWDzQDnECgYAkc8BJyApVCLL/CcJN +/wfcZ39UeFzpXfHDAFtXuKE67MVr5CoPYYlOOZX8qH4oRuVGPcviwH65L5pvWrjd +TG3Xldv0RuIVrTTczQ22SgSeVlXHVv1xnLQan0O9wcw5BvJ++/NRlPVFGBq6s8N4 +d9tdAWFfkqWNbucoWn+vSZTmuw== +-----END PRIVATE KEY----- diff --git a/ee/packages/federation-matrix/docker-compose/traefik/certs/hs1.pem b/ee/packages/federation-matrix/docker-compose/traefik/certs/hs1.pem new file mode 100644 index 0000000000000..4f31357265a59 --- /dev/null +++ b/ee/packages/federation-matrix/docker-compose/traefik/certs/hs1.pem @@ -0,0 +1,27 @@ +-----BEGIN CERTIFICATE----- +MIIEfTCCAuWgAwIBAgIRAL9fzFyRuORHIppJg4++FLMwDQYJKoZIhvcNAQELBQAw +ga0xHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTFBMD8GA1UECww4cm9j +a2V0Y2hhdEBSb2NrZXRDaGF0cy1NYWNCb29rLVByby0yLmxvY2FsIChSb2NrZXQu +Q2hhdCkxSDBGBgNVBAMMP21rY2VydCByb2NrZXRjaGF0QFJvY2tldENoYXRzLU1h +Y0Jvb2stUHJvLTIubG9jYWwgKFJvY2tldC5DaGF0KTAeFw0yNDEyMDEwMTIzNDBa +Fw0yNzAzMDMwMTIzNDBaMGwxJzAlBgNVBAoTHm1rY2VydCBkZXZlbG9wbWVudCBj +ZXJ0aWZpY2F0ZTFBMD8GA1UECww4cm9ja2V0Y2hhdEBSb2NrZXRDaGF0cy1NYWNC +b29rLVByby0yLmxvY2FsIChSb2NrZXQuQ2hhdCkwggEiMA0GCSqGSIb3DQEBAQUA +A4IBDwAwggEKAoIBAQCeBotj7IqU/7C34ac1FTdt28z5T31/0htBrgYreLynEhmr +Kn0pac9WhmGTgXo7y1ep9nmQd6rKYC1VaEhC3PBl3eC55z01G5wgYxo2aQUeJer/ +ZZr25iHeIXAy+GQ8egXa7VuyUZXvDFtA/BauwV5gJGGbAb5bHVH8tZ/DPHMxqfqb +UShk4IZGIR1Lu1VK9y4rvAOWhM3uPYOLhfEkCDExkTtNRHNoTnCY9bt1K0zM6egc +qdegBOrXw4tRKfAJ3qbUL/3c3sf4vSiWMeOM+TmVaeJ3uuDxu8Q/ufELdglnbAJx +AGhKtl/aE4Lvhrqxi6Pc/A4LrgQcBcHhwDjz1KYXAgMBAAGjWDBWMA4GA1UdDwEB +/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAfBgNVHSMEGDAWgBTzAgqAAcA3 +gouaVh/BZtRCgkRLtTAOBgNVHREEBzAFggNoczEwDQYJKoZIhvcNAQELBQADggGB +ADS/JIRP32st6QCkLeT+zmi+aZRaZ2tbs4qEsEWhxSoqpR0fzQTjZ/+9o1cFAh2V +Xc6vfLmZw0kkEOFpu/IovA9qHu3/518V6C+teG7z7o92B2/mif3AxTK9M9A7sbvk +vSInzyz9KQkVq9q/sCTIhSzcW1fbz/X9Li48tdLecP5pJ1xPKcw9cjWZK8oiInuW +KxhcQtOq5xy9zborJaOeoRPTmBBj/A7xl/alFFPZ1yQJVirkobDhb25E5fvcUcm9 +cy6fj6zwdGQRIIekpYdIBa/XYBcNLWllIE/Y1v8pieP7RICQg5Qs+xjOsZD+J2xO +YV2I4aSrHY7Zj/NKpfQsEHrz3zobQVsjNEVpaZ0teyFbI4I1wstmrSYhWp1mE6Ej +DHn2QgixnFKWiqxlplnH0cMup1JUeFMT3mTXxGOzwx7XRpOScOlvKbTrqAZNM5Qy +s94Rt0ZXupS8N+TsvpWkFk5WCfXAfeOWzvCMG+5yMOymPMwbaOB2yRX8PXl0JD49 +QA== +-----END CERTIFICATE----- diff --git a/ee/packages/federation-matrix/docker-compose/traefik/certs/rc1-key.pem b/ee/packages/federation-matrix/docker-compose/traefik/certs/rc1-key.pem new file mode 100644 index 0000000000000..547df07fe7645 --- /dev/null +++ b/ee/packages/federation-matrix/docker-compose/traefik/certs/rc1-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDGKThZLlx8kAmv +2jpfrvusfBfpfYBT5L1jW5QsPBXNzDjTeIQQshAHjYN3HzhThVjxnRyhxGGikdDT +nGdAdnrYyqRMLAxAMLOufVLEhrHbe4r0R1n3KjOKMaVufFJKgarnrcYsvGXm7PQt +w2C9v2feT5xlO8txXrFM+0pIYZbNXwDoSZOnzFPvqvLSb2CzqFzd76dh/xX1H8yx +pNNpfccycP6AtWzkB1ISzO0CwqeSvulfhr3nVcpfSHUyhzor89mdRXmulx9Ly7fs +QWnSE7OUoFO4hNPhpqxnTej02+KJ+XTFyYiOB41lfnXDl4+0X9maAAbAl7Rs9fJp +g64AtgELAgMBAAECggEBAKpCPmT5nYN06q3KTf9qRFkl4hG+wCTU0xhsVfpPwdgp +0OV5ldcWdwlWQI94Jpg0aYBnInOnXNAmgwteRaSwZ8qfCd1ct0Xr0mZozVWH+YcA +Nhq+t4DTJKdnEqHEdZwIn0QiHbTQRqnMC9zKEvPVYjQzqMeeQaVt296txlw8ZSqc +If78JpS8gtmdd+2M81us2lfxpCPge2gL96N6xVT2EvhewWDdul32VgpBwiCh+vjv +OL/MrlBWBU6/T30WVgFGlW0q/oRcm6ifXA/9PR3Co2fhW7nfTdPKFVMGNe1lO/Ij +YqG97MvTsAO9R+KdBLGrvgz3fgVbnfFz2HNW1BUxZ4kCgYEA+aQMJeHflvsZWddC +IHvsK6qNTMmQcmw+iNaVv3YwTw236eOVw8aZMNA2T4UKuKutPKVC5OL5VrqKl9lL +tI+gasjGN6CapaREpc+9uPXIFqc0jN8fQKOiYiHxxG28Ms1dF+xrPfBEuv5RAjox +gTmUc2yPm/znz++4P94clDQHaycCgYEAyzV2s3gWHuyFMCpHBRpzgCma5qJSe2Wt +CJeTX3BoMpPtgWsmWOMWnOGt7oAmeYsw7X6kcFU3YL/+gg05FtYTTuEm9wZUYhlI +FSWRwbtlBGeExjrXy1bSbzWgjT2wxbifRNNdV+gF+NqjwN7X0qWL3YG1KY+6hBaY +OqzCCzzNOX0CgYBgYCHEejgMnLIDyiOgUNczYGueim52ji1yPI2hVep+iohHBnKq +G0DG5IsjFfS2qKh/sjlqDeo+vlOFHgGpGo3Oy+YiUaGCczGBZfsTredqP3D9NaJm +HQYypnIk6ExwvHHFK6OXTOvr2QTDPF4iSm1yRiYHDZMc5qoWFhSobpGynwKBgH9k +I+b8yHlYc2KvjlhPrcrRyk79wdGj+ybgxz3UnS3f+MviXWbp7hopjL1wzy3xKZop +g3L8qTvZAPeMzJZZXD9d/OxtpmbdTIgdRlP6Y6iwMNeIuhG6ey/GocEJxJEfXZFr +JCBgz6Wjg3b8/LYMnDMgBm9osFfwRjy6YudilZGNAoGAJRGgf7cMxAY/NpwHGn/6 +tfajvCkP9QXO7OcxTV989pJ06V3JeNzkWavc7GXGYGNGmMwQ04d+rXGlcSMClnXq +pBgu4jie7PU/dfV2Re5EetaG5KOVrlXilgeY1Shck/RIEp11FfxFBtOQTug/hRki +C1YAl3V5hitWTYsKIMZBFGc= +-----END PRIVATE KEY----- diff --git a/ee/packages/federation-matrix/docker-compose/traefik/certs/rc1.pem b/ee/packages/federation-matrix/docker-compose/traefik/certs/rc1.pem new file mode 100644 index 0000000000000..04439b5385039 --- /dev/null +++ b/ee/packages/federation-matrix/docker-compose/traefik/certs/rc1.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEfDCCAuSgAwIBAgIQNe+/Etw5v2YlMeqJg8IdEDANBgkqhkiG9w0BAQsFADCB +rTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMUEwPwYDVQQLDDhyb2Nr +ZXRjaGF0QFJvY2tldENoYXRzLU1hY0Jvb2stUHJvLTIubG9jYWwgKFJvY2tldC5D +aGF0KTFIMEYGA1UEAww/bWtjZXJ0IHJvY2tldGNoYXRAUm9ja2V0Q2hhdHMtTWFj +Qm9vay1Qcm8tMi5sb2NhbCAoUm9ja2V0LkNoYXQpMB4XDTI0MTIwMTAxMjM1NFoX +DTI3MDMwMzAxMjM1NFowbDEnMCUGA1UEChMebWtjZXJ0IGRldmVsb3BtZW50IGNl +cnRpZmljYXRlMUEwPwYDVQQLDDhyb2NrZXRjaGF0QFJvY2tldENoYXRzLU1hY0Jv +b2stUHJvLTIubG9jYWwgKFJvY2tldC5DaGF0KTCCASIwDQYJKoZIhvcNAQEBBQAD +ggEPADCCAQoCggEBAMYpOFkuXHyQCa/aOl+u+6x8F+l9gFPkvWNblCw8Fc3MONN4 +hBCyEAeNg3cfOFOFWPGdHKHEYaKR0NOcZ0B2etjKpEwsDEAws659UsSGsdt7ivRH +WfcqM4oxpW58UkqBquetxiy8Zebs9C3DYL2/Z95PnGU7y3FesUz7Skhhls1fAOhJ +k6fMU++q8tJvYLOoXN3vp2H/FfUfzLGk02l9xzJw/oC1bOQHUhLM7QLCp5K+6V+G +vedVyl9IdTKHOivz2Z1Fea6XH0vLt+xBadITs5SgU7iE0+GmrGdN6PTb4on5dMXJ +iI4HjWV+dcOXj7Rf2ZoABsCXtGz18mmDrgC2AQsCAwEAAaNYMFYwDgYDVR0PAQH/ +BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFPMCCoABwDeC +i5pWH8Fm1EKCREu1MA4GA1UdEQQHMAWCA3JjMTANBgkqhkiG9w0BAQsFAAOCAYEA +p6r3w1Pk1eTDypgwJop545wjXBPKjUxJkIAAM5NVSjJQ4PvBPjbKlb7CY7SbAH9o +szIQN8SgdBsLmktSkNSml1FOV0R7kjE1eV2nw02FOJwwolOdb+XftHo52c35y8wT +vJhL+TNzzs/q4lU6TAh94zv+3W5QGgE0+PF/ypWUNRiVMBJTBFG5/K14LQS0MQqL +O0w6WAwC1un7ZG4mkP6f9ArjaRUo8uqrfgFAo1GM9aZKCIXZxg/u1hLod4IZAMXh +D6N2A2H4CIJZITkBxDgEZcS+p+2K7M1NW9MBotxhnWSjk+0ZA+VHCCnMxB7sDTJh +HhQt3WiwDYiebCLEiB7AZTBjn3rQAeK8BRqcpbd7p9VwHyu1g91f+HfReOIUj6yb +YH95D23/htN+hP0sFwyWSZzOOHW99bnt529KnLp6QDHDqEXpSXhVrn5ctSuBaxhL +OJ2+Eyb2zzbvInbKsD3HdOttti+sie8sblhUNJ43+IBt6KYGqV2zncez5waMciw7 +-----END CERTIFICATE----- diff --git a/ee/packages/federation-matrix/docker-compose/traefik/dynamic_conf.yml b/ee/packages/federation-matrix/docker-compose/traefik/dynamic_conf.yml new file mode 100644 index 0000000000000..b860ef151ddd3 --- /dev/null +++ b/ee/packages/federation-matrix/docker-compose/traefik/dynamic_conf.yml @@ -0,0 +1,13 @@ +tls: + certificates: + # synapse1 + - certFile: "/etc/traefik/certs/hs1.pem" + keyFile: "/etc/traefik/certs/hs1-key.pem" + + # rocket.chat rc1 (container) + - certFile: "/etc/traefik/certs/rc1.pem" + keyFile: "/etc/traefik/certs/rc1-key.pem" + + # element + - certFile: "/etc/traefik/certs/element.pem" + keyFile: "/etc/traefik/certs/element-key.pem" diff --git a/ee/packages/federation-matrix/docker-compose/traefik/traefik.yml b/ee/packages/federation-matrix/docker-compose/traefik/traefik.yml new file mode 100644 index 0000000000000..330303e97b008 --- /dev/null +++ b/ee/packages/federation-matrix/docker-compose/traefik/traefik.yml @@ -0,0 +1,20 @@ +entryPoints: + web: + address: ":80" + websecure: + address: ":443" + +providers: + docker: + exposedByDefault: false + file: + filename: "/etc/traefik/dynamic_conf.yml" + +api: + dashboard: false + insecure: false + +# accessLog: +# filePath: "/logs/access.log" +# bufferingSize: 100 +# format: json \ No newline at end of file diff --git a/ee/packages/federation-matrix/jest.config.federation.ts b/ee/packages/federation-matrix/jest.config.federation.ts new file mode 100644 index 0000000000000..f123c918481dd --- /dev/null +++ b/ee/packages/federation-matrix/jest.config.federation.ts @@ -0,0 +1,26 @@ +/** + * Jest configuration for federation integration tests. + * + * Extends the base server preset with federation-specific settings including + * extended timeouts for distributed system operations, proper module transformation + * for Matrix SDK dependencies, and global teardown for resource cleanup. + */ +import server from '@rocket.chat/jest-presets/server'; +import type { Config } from 'jest'; + +export default { + preset: server.preset, + transformIgnorePatterns: [ + '/node_modules/@babel', + '/node_modules/@jest', + '/node_modules/(?!marked|@testing-library|matrix-js-sdk|@vector-im)/', + ], + // Federation-specific configuration + testMatch: ['/tests/end-to-end/**/*.spec.ts'], + testTimeout: 30000, // 30 seconds timeout for federation tests + forceExit: true, // Force Jest to exit after tests complete + detectOpenHandles: true, // Detect open handles that prevent Jest from exiting + globalTeardown: '/tests/teardown.ts', + verbose: false, + silent: false, +} satisfies Config; diff --git a/ee/packages/federation-matrix/jest.config.ts b/ee/packages/federation-matrix/jest.config.ts index 5ee40fe48b7a3..6d8be003a50e3 100644 --- a/ee/packages/federation-matrix/jest.config.ts +++ b/ee/packages/federation-matrix/jest.config.ts @@ -8,4 +8,6 @@ export default { '/node_modules/@jest', '/node_modules/(?!marked|@testing-library/)', ], + // Exclude integration/e2e tests from unit test runs + testPathIgnorePatterns: ['/tests/end-to-end/'], } satisfies Config; diff --git a/ee/packages/federation-matrix/package.json b/ee/packages/federation-matrix/package.json index ace17a6ab4f39..1d24e8ef3638b 100644 --- a/ee/packages/federation-matrix/package.json +++ b/ee/packages/federation-matrix/package.json @@ -7,6 +7,7 @@ "@babel/core": "~7.28.4", "@babel/preset-env": "~7.28.3", "@babel/preset-typescript": "~7.27.1", + "@rocket.chat/ddp-client": "workspace:^", "@rocket.chat/eslint-config": "workspace:^", "@types/emojione": "^2.2.9", "@types/node": "~22.16.5", @@ -14,6 +15,7 @@ "babel-jest": "~30.2.0", "eslint": "~8.45.0", "jest": "~30.2.0", + "matrix-js-sdk": "^38.4.0", "pino-pretty": "^7.6.1", "typescript": "~5.9.3" }, @@ -21,6 +23,8 @@ "lint": "eslint src", "lint:fix": "eslint src --fix", "test": "jest", + "testend-to-end": "IS_EE=true NODE_EXTRA_CA_CERTS=$(pwd)/docker-compose/traefik/certs/ca/rootCA.crt jest --config jest.config.federation.ts --forceExit --testTimeout=30000", + "test:integration": "./tests/scripts/run-integration-tests.sh", "build": "rm -rf dist && tsc -p tsconfig.build.json", "testunit": "jest", "typecheck": "tsc --noEmit --skipLibCheck", diff --git a/ee/packages/federation-matrix/tests/end-to-end/messaging.spec.ts b/ee/packages/federation-matrix/tests/end-to-end/messaging.spec.ts new file mode 100644 index 0000000000000..06abc2d47434b --- /dev/null +++ b/ee/packages/federation-matrix/tests/end-to-end/messaging.spec.ts @@ -0,0 +1,675 @@ +import type { IMessage } from '@rocket.chat/core-typings'; + +import { sendMessage } from '../../../../../apps/meteor/tests/data/messages.helper'; +import { createRoom, loadHistory } from '../../../../../apps/meteor/tests/data/rooms.helper'; +import { getRequestConfig, createUser } from '../../../../../apps/meteor/tests/data/users.helper'; +import { IS_EE } from '../../../../../apps/meteor/tests/e2e/config/constants'; +import { federationConfig } from '../helper/config'; +import { SynapseClient } from '../helper/synapse-client'; + +(IS_EE ? describe : describe.skip)('Federation', () => { + let rc1AdminRequestConfig: any; + let hs1AdminApp: SynapseClient; + let hs1User1App: SynapseClient; + + beforeAll(async () => { + // Create admin request config for RC1 + rc1AdminRequestConfig = await getRequestConfig( + federationConfig.rc1.apiUrl, + federationConfig.rc1.adminUser, + federationConfig.rc1.adminPassword, + ); + + // Create user1 in RC1 using federation config values + 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, + ); + + // Create admin Synapse client for HS1 + hs1AdminApp = new SynapseClient(federationConfig.hs1.url, federationConfig.hs1.adminUser, federationConfig.hs1.adminPassword); + await hs1AdminApp.initialize(); + + // Create user1 Synapse client for HS1 + hs1User1App = new SynapseClient( + federationConfig.hs1.url, + federationConfig.hs1.additionalUser1.matrixUserId, + federationConfig.hs1.additionalUser1.password, + ); + await hs1User1App.initialize(); + }); + + afterAll(async () => { + if (hs1AdminApp) { + await hs1AdminApp.close(); + } + if (hs1User1App) { + await hs1User1App.close(); + } + }); + + describe('Messaging', () => { + describe('Basic messaging', () => { + describe('On RC1', () => { + let channelName: string; + let federatedChannel: any; + + beforeAll(async () => { + channelName = `federated-room-messaging-rc1-${Date.now()}`; + + // Create a federated private room with federated user + const createResponse = await createRoom({ + type: 'p', + name: channelName, + members: [federationConfig.hs1.adminMatrixUserId], + extraData: { + federated: true, + }, + config: rc1AdminRequestConfig, + }); + + federatedChannel = createResponse.body.group; + + expect(federatedChannel).toHaveProperty('_id'); + expect(federatedChannel).toHaveProperty('name', channelName); + expect(federatedChannel).toHaveProperty('t', 'p'); + expect(federatedChannel).toHaveProperty('federated', true); + + // Accept invitation for the federated user + const acceptedRoomId = await hs1AdminApp.acceptInvitationForRoomName(channelName); + expect(acceptedRoomId).not.toBe(''); + + // Wait for federation synchronization + await new Promise((resolve) => setTimeout(resolve, 2000)); + }, 10000); + + it('Send a text message', async () => { + const messageText = 'Hello from RC1'; + + // RC view: Send a text message from RC1 + const sendResponse = await sendMessage({ + rid: federatedChannel._id, + msg: messageText, + config: rc1AdminRequestConfig, + }); + + expect(sendResponse.body).toHaveProperty('success', true); + + // RC view: Verify message appears in RC1 + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.msg === messageText); + expect(rcMessage).toBeDefined(); + expect(rcMessage?.msg).toBe(messageText); + + // Synapse view: Verify message appears correctly on remote Element + const synapseMessage = await hs1AdminApp.findMessageInRoom(channelName, messageText); + expect(synapseMessage?.content.body).toBe(messageText); + }); + + it('Send a text message containing an emoji via shortcut like :rocket: and another one entered via system', async () => { + const messageText = 'Hello :rocket: from RC1 🚀'; + + // RC view: Send a text message with emoji shortcut and system emoji from RC1 + const sendResponse = await sendMessage({ + rid: federatedChannel._id, + msg: messageText, + config: rc1AdminRequestConfig, + }); + + expect(sendResponse.body).toHaveProperty('success', true); + + // RC view: Verify message appears in RC1 + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.msg === messageText); + expect(rcMessage).toBeDefined(); + + // Assert the md attribute matches the expected structure exactly + const expectedMd = [ + { + type: 'PARAGRAPH', + value: [ + { type: 'PLAIN_TEXT', value: 'Hello ' }, + { + type: 'EMOJI', + value: { type: 'PLAIN_TEXT', value: 'rocket' }, + shortCode: 'rocket', + }, + { type: 'PLAIN_TEXT', value: ' from RC1 ' }, + { type: 'EMOJI', value: null, unicode: '🚀' }, + ], + }, + ]; + expect(rcMessage?.md).toEqual(expectedMd); + + // TODO: Verify emojis are correctly translated + // Synapse view: Verify message appears correctly on remote Element + const synapseMessage = await hs1AdminApp.findMessageInRoom(channelName, messageText); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.body).toBe(messageText); + }); + + it('Send just a single emoji using a shortcut', async () => { + const messageText = ':smirk:'; + + // RC view: Send a single emoji shortcut from RC1 + const sendResponse = await sendMessage({ + rid: federatedChannel._id, + msg: messageText, + config: rc1AdminRequestConfig, + }); + + expect(sendResponse.body).toHaveProperty('success', true); + + // RC view: Verify message appears in RC1 + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.msg === messageText); + expect(rcMessage).toBeDefined(); + + // Assert the md attribute matches the expected structure exactly + const expectedMd = [ + { + type: 'BIG_EMOJI', + value: [ + { + type: 'EMOJI', + value: { type: 'PLAIN_TEXT', value: 'smirk' }, + shortCode: 'smirk', + }, + ], + }, + ]; + expect(rcMessage?.md).toEqual(expectedMd); + + // TODO: Verify emojis are correctly translated + // Synapse view: Verify message appears correctly on remote Element + const synapseMessage = await hs1AdminApp.findMessageInRoom(channelName, messageText); + expect(synapseMessage?.content.body).toBe(messageText); + }); + + it('Send just a single emoji via system emoji', async () => { + const messageText = '😀'; + + // RC view: Send a single system emoji from RC1 + const sendResponse = await sendMessage({ + rid: federatedChannel._id, + msg: messageText, + config: rc1AdminRequestConfig, + }); + + expect(sendResponse.body).toHaveProperty('success', true); + + // RC view: Verify message appears in RC1 + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.msg === messageText); + expect(rcMessage).toBeDefined(); + + // Assert the md attribute matches the expected structure exactly + const expectedMd = [ + { + type: 'BIG_EMOJI', + value: [{ type: 'EMOJI', value: null, unicode: '😀' }], + }, + ]; + expect(rcMessage?.md).toEqual(expectedMd); + + // Synapse view: Verify message appears correctly on remote Element + const synapseMessage = await hs1AdminApp.findMessageInRoom(channelName, messageText); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.body).toContain(messageText); + }); + + it('Send a message containing plain text, bold, italic, and underlined text', async () => { + const messageText = 'Plain text **bold** _italic_ __underline__'; + + // RC view: Send a formatted text message from RC1 + const sendResponse = await sendMessage({ + rid: federatedChannel._id, + msg: messageText, + config: rc1AdminRequestConfig, + }); + + expect(sendResponse.body).toHaveProperty('success', true); + + // Wait for message to propagate + // await new Promise((resolve) => setTimeout(resolve, 2000)); + + // RC view: Verify message appears in RC1 + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.msg === messageText); + expect(rcMessage).toBeDefined(); + + // Synapse view: Verify message appears correctly on remote Element + const synapseMessage = await hs1AdminApp.findMessageInRoom(channelName, messageText); + expect(synapseMessage?.content.body).toBe(messageText); + }); + + it('Send a message containing a plain link address', async () => { + const messageText = 'Check this link: https://www.wikipedia.org'; + + // RC view: Send a message with plain link from RC1 + const sendResponse = await sendMessage({ + rid: federatedChannel._id, + msg: messageText, + config: rc1AdminRequestConfig, + }); + + expect(sendResponse.body).toHaveProperty('success', true); + + // RC view: Verify message appears in RC1 + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.msg === messageText); + expect(rcMessage).toBeDefined(); + expect(rcMessage?.msg).toBe(messageText); + + const expectedMd = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'Check this link: ', + }, + { + type: 'LINK', + value: { + src: { + type: 'PLAIN_TEXT', + value: 'https://www.wikipedia.org', + }, + label: [ + { + type: 'PLAIN_TEXT', + value: 'https://www.wikipedia.org', + }, + ], + }, + }, + ], + }, + ]; + expect(rcMessage?.md).toEqual(expectedMd); + + // Synapse view: Verify message appears correctly on remote Element + const synapseMessage = await hs1AdminApp.findMessageInRoom(channelName, messageText); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.body).toBe(messageText); + }); + + it('Send a message containing a markdown link address [google](google.com)', async () => { + const messageText = 'Check this [google](google.com) link'; + + // RC view: Send a message with markdown link from RC1 + const sendResponse = await sendMessage({ + rid: federatedChannel._id, + msg: messageText, + config: rc1AdminRequestConfig, + }); + + expect(sendResponse.body).toHaveProperty('success', true); + + // RC view: Verify message appears in RC1 + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.msg === messageText); + expect(rcMessage).toBeDefined(); + expect(rcMessage?.msg).toBe(messageText); + const expectedMd = [ + { + type: 'PARAGRAPH', + value: [ + { type: 'PLAIN_TEXT', value: 'Check this ' }, + { + type: 'LINK', + value: { src: { type: 'PLAIN_TEXT', value: 'google.com' }, label: [{ type: 'PLAIN_TEXT', value: 'google' }] }, + }, + { type: 'PLAIN_TEXT', value: ' link' }, + ], + }, + ]; + expect(rcMessage?.md).toEqual(expectedMd); + + // Synapse view: Verify message appears correctly on remote Element + const synapseMessage = await hs1AdminApp.findMessageInRoom(channelName, messageText); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.body).toBe(messageText); + }); + + it('Send a message containing a code block', async () => { + const messageText = 'Here is some code:\n```\nconst x = 1;\n```'; + + // RC view: Send a message with code block from RC1 + const sendResponse = await sendMessage({ + rid: federatedChannel._id, + msg: messageText, + config: rc1AdminRequestConfig, + }); + + expect(sendResponse.body).toHaveProperty('success', true); + + // RC view: Verify message appears in RC1 + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.msg === messageText); + expect(rcMessage).toBeDefined(); + expect(rcMessage?.msg).toBe(messageText); + const expectedMd = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'Here is some code:', + }, + ], + }, + { + type: 'CODE', + language: 'none', + value: [ + { + type: 'CODE_LINE', + value: { + type: 'PLAIN_TEXT', + value: 'const x = 1;', + }, + }, + ], + }, + ]; + expect(rcMessage?.md).toEqual(expectedMd); + + // Synapse view: Verify message appears correctly on remote Element + const synapseMessage = await hs1AdminApp.findMessageInRoom(channelName, messageText); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.body).toBe(messageText); + }); + }); + + describe('On Element', () => { + let channelName: string; + let federatedChannel: any; + + beforeAll(async () => { + channelName = `federated-room-messaging-element-${Date.now()}`; + + // Create a federated private room with federated user + const createResponse = await createRoom({ + type: 'p', + name: channelName, + members: [federationConfig.hs1.adminMatrixUserId], + extraData: { + federated: true, + }, + config: rc1AdminRequestConfig, + }); + + federatedChannel = createResponse.body.group; + + expect(federatedChannel).toHaveProperty('_id'); + expect(federatedChannel).toHaveProperty('name', channelName); + expect(federatedChannel).toHaveProperty('t', 'p'); + expect(federatedChannel).toHaveProperty('federated', true); + + // Accept invitation for the federated user + const acceptedRoomId = await hs1AdminApp.acceptInvitationForRoomName(channelName); + expect(acceptedRoomId).not.toBe(''); + + // Wait for federation synchronization + await new Promise((resolve) => setTimeout(resolve, 2000)); + }, 10000); + + it('Send a text message', async () => { + const messageText = 'Hello from Element'; + + // Synapse view: Send a text message from Element + await hs1AdminApp.sendTextMessage(channelName, messageText); + + // Synapse view: Verify message appears in Element + const synapseMessage = await hs1AdminApp.findMessageInRoom(channelName, messageText); + expect(synapseMessage?.content.body).toBe(messageText); + + // RC view: Verify message appears correctly on remote RC1 + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.msg === messageText); + expect(rcMessage).toBeDefined(); + expect(rcMessage?.msg).toBe(messageText); + }); + + it('Send a text message containing an emoji via shortcut like :rocket: and another one entered via system', async () => { + const messageText = 'Hello :rocket: from Element 🚀'; + + // Synapse view: Send a text message with emoji shortcut and system emoji from Element + await hs1AdminApp.sendTextMessage(channelName, messageText); + + // Synapse view: Verify message appears in Element + const synapseMessage = await hs1AdminApp.findMessageInRoom(channelName, messageText); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.body).toBe(messageText); + + // RC view: Verify message appears correctly on remote RC1 + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.msg === messageText); + expect(rcMessage).toBeDefined(); + + // Assert the md attribute matches the expected structure exactly + const expectedMd = [ + { + type: 'PARAGRAPH', + value: [ + { type: 'PLAIN_TEXT', value: 'Hello ' }, + { + type: 'EMOJI', + value: { type: 'PLAIN_TEXT', value: 'rocket' }, + shortCode: 'rocket', + }, + { type: 'PLAIN_TEXT', value: ' from Element ' }, + { type: 'EMOJI', value: null, unicode: '🚀' }, + ], + }, + ]; + expect(rcMessage?.md).toEqual(expectedMd); + }); + + it('Send just a single emoji using a shortcut', async () => { + const messageText = ':smirk:'; + + // Synapse view: Send a single emoji shortcut from Element + await hs1AdminApp.sendTextMessage(channelName, messageText); + + // Synapse view: Verify message appears in Element + const synapseMessage = await hs1AdminApp.findMessageInRoom(channelName, messageText); + expect(synapseMessage?.content.body).toBe(messageText); + + // RC view: Verify message appears correctly on remote RC1 + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.msg === messageText); + expect(rcMessage).toBeDefined(); + + // Assert the md attribute matches the expected structure exactly + const expectedMd = [ + { + type: 'BIG_EMOJI', + value: [ + { + type: 'EMOJI', + value: { type: 'PLAIN_TEXT', value: 'smirk' }, + shortCode: 'smirk', + }, + ], + }, + ]; + expect(rcMessage?.md).toEqual(expectedMd); + }); + + it('Send just a single emoji via system emoji', async () => { + const messageText = '😀'; + + // Synapse view: Send a single system emoji from Element + await hs1AdminApp.sendTextMessage(channelName, messageText); + + // Synapse view: Verify message appears in Element + const synapseMessage = await hs1AdminApp.findMessageInRoom(channelName, messageText); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.body).toContain(messageText); + + // RC view: Verify message appears correctly on remote RC1 + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.msg === messageText); + expect(rcMessage).toBeDefined(); + + // Assert the md attribute matches the expected structure exactly + const expectedMd = [ + { + type: 'BIG_EMOJI', + value: [{ type: 'EMOJI', value: null, unicode: '😀' }], + }, + ]; + expect(rcMessage?.md).toEqual(expectedMd); + }); + + it('Send a message containing plain text, bold, italic, and underlined text', async () => { + const messageText = 'Plain text **bold** _italic_ __underline__'; + const htmlFormattedBody = 'Plain text bold italic underline'; + + // Synapse view: Send a formatted text message from Element with HTML formatting + await hs1AdminApp.sendHtmlMessage(channelName, messageText, htmlFormattedBody); + + // Synapse view: Verify message appears in Element + const synapseMessage = await hs1AdminApp.findMessageInRoom(channelName, messageText); + expect(synapseMessage?.content.body).toBe(messageText); + + // RC view: Verify message appears correctly on remote RC1 + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.msg === messageText); + expect(rcMessage).toBeDefined(); + }); + + it('Send a message containing a plain link address', async () => { + const messageText = 'Check this link: https://www.wikipedia.org'; + + // Synapse view: Send a message with plain link from Element + await hs1AdminApp.sendTextMessage(channelName, messageText); + + // Synapse view: Verify message appears in Element + const synapseMessage = await hs1AdminApp.findMessageInRoom(channelName, messageText); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.body).toBe(messageText); + + // RC view: Verify message appears correctly on remote RC1 + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.msg === messageText); + expect(rcMessage).toBeDefined(); + expect(rcMessage?.msg).toBe(messageText); + + const expectedMd = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'Check this link: ', + }, + { + type: 'LINK', + value: { + src: { + type: 'PLAIN_TEXT', + value: 'https://www.wikipedia.org', + }, + label: [ + { + type: 'PLAIN_TEXT', + value: 'https://www.wikipedia.org', + }, + ], + }, + }, + ], + }, + ]; + expect(rcMessage?.md).toEqual(expectedMd); + }); + + it('Send a message containing a markdown link address [google](google.com)', async () => { + const messageText = 'Check this [google](google.com) link'; + const htmlFormattedBody = 'Check this google link'; + + // Synapse view: Send a message with markdown link from Element with HTML formatting + await hs1AdminApp.sendHtmlMessage(channelName, messageText, htmlFormattedBody); + + // Synapse view: Verify message appears in Element + const synapseMessage = await hs1AdminApp.findMessageInRoom(channelName, messageText); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.body).toBe(messageText); + + // RC view: Verify message appears correctly on remote RC1 + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.msg === messageText); + expect(rcMessage).toBeDefined(); + expect(rcMessage?.msg).toBe(messageText); + const expectedMd = [ + { + type: 'PARAGRAPH', + value: [ + { type: 'PLAIN_TEXT', value: 'Check this ' }, + { + type: 'LINK', + value: { src: { type: 'PLAIN_TEXT', value: 'google.com' }, label: [{ type: 'PLAIN_TEXT', value: 'google' }] }, + }, + { type: 'PLAIN_TEXT', value: ' link' }, + ], + }, + ]; + expect(rcMessage?.md).toEqual(expectedMd); + }); + + it('Send a message containing a code block', async () => { + const messageText = 'Here is some code:\n```\nconst x = 1;\n```'; + const htmlFormattedBody = 'Here is some code:
const x = 1;
'; + + // Synapse view: Send a message with code block from Element with HTML formatting + await hs1AdminApp.sendHtmlMessage(channelName, messageText, htmlFormattedBody); + + // Synapse view: Verify message appears in Element + const synapseMessage = await hs1AdminApp.findMessageInRoom(channelName, messageText); + expect(synapseMessage).not.toBeNull(); + expect(synapseMessage?.content.body).toBe(messageText); + + // RC view: Verify message appears correctly on remote RC1 + const historyResponse = await loadHistory(federatedChannel._id, rc1AdminRequestConfig); + const rcMessage = historyResponse.messages.find((message: IMessage) => message.msg === messageText); + expect(rcMessage).toBeDefined(); + expect(rcMessage?.msg).toBe(messageText); + const expectedMd = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'Here is some code:', + }, + ], + }, + { + type: 'CODE', + language: 'none', + value: [ + { + type: 'CODE_LINE', + value: { + type: 'PLAIN_TEXT', + value: 'const x = 1;', + }, + }, + ], + }, + ]; + expect(rcMessage?.md).toEqual(expectedMd); + }); + }); + }); + }); +}); diff --git a/ee/packages/federation-matrix/tests/end-to-end/room.spec.ts b/ee/packages/federation-matrix/tests/end-to-end/room.spec.ts new file mode 100644 index 0000000000000..3987331816fd1 --- /dev/null +++ b/ee/packages/federation-matrix/tests/end-to-end/room.spec.ts @@ -0,0 +1,1451 @@ +import type { IMessage } from '@rocket.chat/core-typings'; + +import { + createRoom, + getRoomInfo, + getGroupHistory, + findRoomMember, + addUserToRoom, + addUserToRoomSlashCommand, +} from '../../../../../apps/meteor/tests/data/rooms.helper'; +import { getRequestConfig, createUser } from '../../../../../apps/meteor/tests/data/users.helper'; +import { IS_EE } from '../../../../../apps/meteor/tests/e2e/config/constants'; +import { federationConfig } from '../helper/config'; +import { createDDPListener } from '../helper/ddp-listener'; +import { SynapseClient } from '../helper/synapse-client'; + +// import { KnownMembership } from 'matrix-js-sdk'; +// import { t } from 'i18next'; + +(IS_EE ? describe : describe.skip)('Federation', () => { + let rc1AdminRequestConfig: any; + let rc1User1RequestConfig: any; + let hs1AdminApp: SynapseClient; + let hs1User1App: SynapseClient; + + beforeAll(async () => { + // Create admin request config for RC1 + rc1AdminRequestConfig = await getRequestConfig( + federationConfig.rc1.apiUrl, + federationConfig.rc1.adminUser, + federationConfig.rc1.adminPassword, + ); + + // Create user1 in RC1 using federation config values + 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, + ); + + // Create user1 request config for RC1 + rc1User1RequestConfig = await getRequestConfig( + federationConfig.rc1.apiUrl, + 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(); + + // Create user1 Synapse client for HS1 + hs1User1App = new SynapseClient( + federationConfig.hs1.url, + federationConfig.hs1.additionalUser1.matrixUserId, + federationConfig.hs1.additionalUser1.password, + ); + await hs1User1App.initialize(); + }); + + afterAll(async () => { + if (hs1AdminApp) { + await hs1AdminApp.close(); + } + if (hs1User1App) { + await hs1User1App.close(); + } + }); + + describe('Rooms', () => { + describe('Create a room on RC as private, explicitly not federated, with federated users in creation modal', () => { + describe('Add 1 federated user in the creation modal', () => { + it('It should not allow the creation of the room', async () => { + const channelName = `non-federated-channel-single-fed-${Date.now()}`; + + // RC view: Attempt to create a non-federated private room with 1 federated user + const response = await createRoom({ + type: 'p', + name: channelName, + members: [federationConfig.hs1.adminMatrixUserId], + extraData: { + federated: false, + }, + config: rc1AdminRequestConfig, + }); + + // RC view: Verify the room creation failed + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('success', false); + expect(response.body).toHaveProperty('errorType', 'error-federated-users-in-non-federated-rooms'); + }); + }); + + describe('Add 2 federated users in the creation modal', () => { + it('It should not allow the creation of the room', async () => { + const channelName = `non-federated-channel-multi-fed-${Date.now()}`; + + // RC view: Attempt to create a non-federated private room with 2 federated users + const response = await createRoom({ + type: 'p', + name: channelName, + members: [federationConfig.hs1.adminMatrixUserId, federationConfig.hs1.additionalUser1.matrixUserId], + extraData: { + federated: false, + }, + config: rc1AdminRequestConfig, + }); + + // RC view: Verify the room creation failed + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('success', false); + expect(response.body).toHaveProperty('errorType', 'error-federated-users-in-non-federated-rooms'); + }); + }); + + describe('Add 1 federated user and 1 local user in the creation modal', () => { + it('It should not allow the creation of the room', async () => { + const channelName = `non-federated-channel-mixed-${Date.now()}`; + + // RC view: Attempt to create a non-federated private room with 1 federated user and 1 local user + const response = await createRoom({ + type: 'p', + name: channelName, + members: [federationConfig.hs1.adminMatrixUserId, federationConfig.rc1.additionalUser1.username], + extraData: { + federated: false, + }, + config: rc1AdminRequestConfig, + }); + + // RC view: Verify the room creation failed + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('success', false); + expect(response.body).toHaveProperty('errorType', 'error-federated-users-in-non-federated-rooms'); + }); + }); + }); + + describe('Create a room on RC as private, do not mark as federated and', () => { + let nonFederatedChannel: { _id: string; name: string; t: string; federated?: boolean }; + + beforeEach(async () => { + const channelName = `non-federated-channel-${Date.now()}`; + + // Create a non-federated private room (without federated members) + const createResponse = await createRoom({ + type: 'p', + name: channelName, + members: [], + extraData: { + federated: false, + }, + config: rc1AdminRequestConfig, + }); + + nonFederatedChannel = createResponse.body.group; + + expect(nonFederatedChannel).toHaveProperty('_id'); + expect(nonFederatedChannel).toHaveProperty('name', channelName); + expect(nonFederatedChannel).toHaveProperty('t', 'p'); + expect(nonFederatedChannel).not.toHaveProperty('federated', true); + }, 10000); + + // No cleanup needed - rooms are left for debugging purposes + + describe('Go to the members list and try to add a federated user', () => { + it('It should not allow and show an error message', async () => { + // RC view: Attempt to add a federated user to the non-federated room + const response = await addUserToRoom({ + usernames: [federationConfig.hs1.adminMatrixUserId], + rid: nonFederatedChannel._id, + config: rc1AdminRequestConfig, + }); + + console.log('response', response.body); + + expect(response.body).toHaveProperty('success', true); + expect(response.body).toHaveProperty('message'); + + // Parse the error message from the DDP response + const messageData = JSON.parse(response.body.message); + expect(messageData).toHaveProperty('error'); + expect(messageData.error).toHaveProperty('error', 'error-federated-users-in-non-federated-rooms'); + + // RC view: Verify the federated user was NOT added to the room's member list + const federatedUserInRoom = await findRoomMember( + nonFederatedChannel._id, + federationConfig.hs1.adminMatrixUserId, + { initialDelay: 0 }, + rc1AdminRequestConfig, + ); + expect(federatedUserInRoom).toBeNull(); + + // RC view: Verify room remains non-federated + const roomInfo = await getRoomInfo(nonFederatedChannel._id, rc1AdminRequestConfig); + expect(roomInfo.room).not.toHaveProperty('federated', true); + }); + }); + + describe('Go to the composer and use the /invite slash command to add a federated user', () => { + it('It should not allow and show an error message', async () => { + // Set up DDP listener to catch ephemeral messages + const ddpListener = createDDPListener(federationConfig.rc1.apiUrl, rc1AdminRequestConfig); + + // Connect to DDP and subscribe to ephemeral messages + await ddpListener.connect(); + + // RC view: Execute the /invite slash command to add a federated user + const response = await addUserToRoomSlashCommand({ + usernames: [federationConfig.hs1.adminMatrixUserId], + rid: nonFederatedChannel._id, + config: rc1AdminRequestConfig, + }); + + // The slash command returns success but broadcasts ephemeral messages + // instead of throwing errors + expect(response.body).toHaveProperty('success', true); + expect(response.body).toHaveProperty('message'); + const messageData = JSON.parse(response.body.message); + expect(messageData).toHaveProperty('msg', 'result'); + + // Wait for the ephemeral message to be broadcast + const ephemeralMessage = await ddpListener.waitForEphemeralMessage( + 'You cannot add external users to a non-federated room', + 5000, // 5 second timeout + nonFederatedChannel._id, + ); + + // Verify the ephemeral message content + expect(ephemeralMessage.msg).toContain('You cannot add external users to a non-federated room'); + expect(ephemeralMessage.u.username).toBe('rocket.cat'); + expect(ephemeralMessage.private).toBe(true); + expect(ephemeralMessage.rid).toBe(nonFederatedChannel._id); // Verify it's for the correct room + + // RC view: Verify the federated user was NOT added to the room's member list + const federatedUserInRoom = await findRoomMember( + nonFederatedChannel._id, + federationConfig.hs1.adminMatrixUserId, + {}, + rc1AdminRequestConfig, + ); + expect(federatedUserInRoom).toBeNull(); + + // RC view: Verify room remains non-federated + const roomInfo = await getRoomInfo(nonFederatedChannel._id, rc1AdminRequestConfig); + expect(roomInfo.room).not.toHaveProperty('federated', true); + + ddpListener.disconnect(); + }); + }); + }); + + describe('Create a room on RC as private and federated and', () => { + describe('Add 1 federated user in the creation modal', () => { + let channelName: string; + let federatedChannel: any; + + beforeAll(async () => { + channelName = `federated-channel-${Date.now()}`; + + const createResponse = await createRoom({ + type: 'p', + name: channelName, + members: [federationConfig.hs1.adminMatrixUserId], + extraData: { + federated: true, + }, + config: rc1AdminRequestConfig, + }); + + // For private groups, the response has 'group' property, not 'channel' + federatedChannel = createResponse.body.group; + + expect(federatedChannel).toHaveProperty('_id'); + expect(federatedChannel).toHaveProperty('name', channelName); + expect(federatedChannel).toHaveProperty('t', 'p'); + expect(federatedChannel).toHaveProperty('federated', true); + expect(federatedChannel).toHaveProperty('federation'); + expect((federatedChannel as any).federation).toHaveProperty('version', 1); + + const acceptedRoomId = await hs1AdminApp.acceptInvitationForRoomName(channelName); + expect(acceptedRoomId).not.toBe(''); + + // TODO: Figure out why syncing events are not working and uncomment this when we get the state change from + // invite to join + // const joinedRoomId = await this.hs1App.getRoomIdByRoomNameAndMembership(channelName, KnownMembership.Join); + // expect(acceptedRoomId, 'Expected to have joined the room, but joinedRoomId is different from acceptedRoomId').to.equal(joinedRoomId); + }, 10000); + + it('It should show the room on the remote Element or RC', async () => { + // RC view: Check in RC + const roomInfo = await getRoomInfo(federatedChannel._id, rc1AdminRequestConfig); + expect(roomInfo.room).toHaveProperty('_id', federatedChannel._id); + expect(roomInfo.room).toHaveProperty('federated', true); + + // Synapse view: Check in Element + const elementRoom = hs1AdminApp.getRoom(channelName); + expect(elementRoom).toHaveProperty('name', channelName); + }); + + it('It should show the new user in the members list', async () => { + // RC view: Check in RC that the federated user is in the members list + const rc1AdminUserInRC = await findRoomMember( + federatedChannel._id, + federationConfig.rc1.adminUser, + { initialDelay: 0 }, + rc1AdminRequestConfig, + ); + const hs1AdminUserInRC = await findRoomMember( + federatedChannel._id, + federationConfig.hs1.adminMatrixUserId, + { initialDelay: 0 }, + rc1AdminRequestConfig, + ); + + expect(rc1AdminUserInRC).not.toBeNull(); + expect(hs1AdminUserInRC).not.toBeNull(); + expect(hs1AdminUserInRC?.federated).toBe(true); + + // Synapse view: Check in Element (Matrix) that the federated user is in the members list + const rc1AdminUserInSynapse = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.adminMatrixUserId, { + delay: 2000, + }); + const hs1AdminUserInSynapse = await hs1AdminApp.findRoomMember(channelName, federationConfig.hs1.adminMatrixUserId, { + delay: 2000, + }); + expect(rc1AdminUserInSynapse).not.toBeNull(); + expect(hs1AdminUserInSynapse).not.toBeNull(); + }); + + it('It should show the system message that the user added', async () => { + // RC view: Check in RC. We don't check in Synapse because this is not part of the protocol + // Get the room history to find the system message + const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); + expect(Array.isArray(historyResponse.messages)).toBe(true); + + // Look for a system message about the user joining + // System messages typically have t: 'uj' (user joined) and the msg contains the username + const joinMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg === federationConfig.hs1.adminMatrixUserId, + ); + + expect(joinMessage).toBeDefined(); + expect(joinMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(joinMessage?.u?.username).toBe(federationConfig.hs1.adminMatrixUserId); + }); + }); + + describe('Add 2 or more federated users in the creation modal', () => { + let channelName: string; + let federatedChannel: any; + + beforeAll(async () => { + channelName = `federated-channel-multi-${Date.now()}`; + + // Create room with both federated users + const createResponse = await createRoom({ + type: 'p', + name: channelName, + members: [federationConfig.hs1.adminMatrixUserId, federationConfig.hs1.additionalUser1.matrixUserId], + extraData: { + federated: true, + }, + config: rc1AdminRequestConfig, + }); + + // For private groups, the response has 'group' property, not 'channel' + federatedChannel = createResponse.body.group; + + expect(federatedChannel).toHaveProperty('_id'); + expect(federatedChannel).toHaveProperty('name', channelName); + expect(federatedChannel).toHaveProperty('t', 'p'); + expect(federatedChannel).toHaveProperty('federated', true); + expect(federatedChannel).toHaveProperty('federation'); + expect((federatedChannel as any).federation).toHaveProperty('version', 1); + + // Accept invitations for both users + const acceptedRoomId1 = await hs1AdminApp.acceptInvitationForRoomName(channelName); + expect(acceptedRoomId1).not.toBe(''); + + const acceptedRoomId2 = await hs1User1App.acceptInvitationForRoomName(channelName); + expect(acceptedRoomId2).not.toBe(''); + + // TODO: Figure out why syncing events are not working and uncomment this when we get the state change from + // invite to join + // const joinedRoomId = await this.hs1App.getRoomIdByRoomNameAndMembership(channelName, KnownMembership.Join); + // expect(acceptedRoomId, 'Expected to have joined the room, but joinedRoomId is different from acceptedRoomId').to.equal(joinedRoomId); + }, 15000); + + it('It should show the room on all the involved remote Element or RC', async () => { + // RC view: Check in RC + const roomInfo = await getRoomInfo(federatedChannel._id, rc1AdminRequestConfig); + expect(roomInfo.room).toHaveProperty('_id', federatedChannel._id); + expect(roomInfo.room).toHaveProperty('federated', true); + + // Synapse view: Check in Element for admin user + const elementRoom1 = hs1AdminApp.getRoom(channelName); + expect(elementRoom1).toHaveProperty('name', channelName); + + // Synapse view: Check in Element for user1 + const elementRoom2 = hs1User1App.getRoom(channelName); + expect(elementRoom2).toHaveProperty('name', channelName); + }); + + it('It should show the new users in the members list of all RCs involved', async () => { + // RC view: Check in RC that both federated users are in the members list + const rc1AdminUserInRC = await findRoomMember(federatedChannel._id, federationConfig.rc1.adminUser, {}, rc1AdminRequestConfig); + const hs1AdminUserInRC = await findRoomMember( + federatedChannel._id, + federationConfig.hs1.adminMatrixUserId, + {}, + rc1AdminRequestConfig, + ); + const hs1User1InRC = await findRoomMember( + federatedChannel._id, + federationConfig.hs1.additionalUser1.matrixUserId, + {}, + rc1AdminRequestConfig, + ); + + expect(rc1AdminUserInRC).not.toBeNull(); + expect(hs1AdminUserInRC).not.toBeNull(); + expect(hs1User1InRC).not.toBeNull(); + expect(hs1AdminUserInRC?.federated).toBe(true); + expect(hs1User1InRC?.federated).toBe(true); + + // Synapse view: Check in Synapse (Matrix) for admin user that all users are in the members list + const rc1AdminUserInSynapseAdmin = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.adminMatrixUserId); + const hs1AdminUserInSynapseAdmin = await hs1AdminApp.findRoomMember(channelName, federationConfig.hs1.adminMatrixUserId); + const hs1User1InSynapseAdmin = await hs1AdminApp.findRoomMember(channelName, federationConfig.hs1.additionalUser1.matrixUserId, { + initialDelay: 2000, + }); + + expect(rc1AdminUserInSynapseAdmin).not.toBeNull(); + expect(hs1AdminUserInSynapseAdmin).not.toBeNull(); + expect(hs1User1InSynapseAdmin).not.toBeNull(); + + // Synapse view: Check in Synapse (Matrix) for additional user that all users are in the members list + const rc1AdminUserInSynapseUser1 = await hs1User1App.findRoomMember(channelName, federationConfig.rc1.adminMatrixUserId); + const hs1AdminUserInSynapseUser1 = await hs1User1App.findRoomMember(channelName, federationConfig.hs1.adminMatrixUserId); + const hs1User1InSynapseUser1 = await hs1User1App.findRoomMember(channelName, federationConfig.hs1.additionalUser1.matrixUserId); + + expect(rc1AdminUserInSynapseUser1).not.toBeNull(); + expect(hs1AdminUserInSynapseUser1).not.toBeNull(); + expect(hs1User1InSynapseUser1).not.toBeNull(); + }); + + it('It should show the system messages that the user added on all RCs involved', async () => { + // RC view: Check in RC. We don't check in Synapse because this is not part of the protocol + // Get the room history to find the system messages + const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); + expect(Array.isArray(historyResponse.messages)).toBe(true); + + // Look for system messages about both users joining + // System messages typically have t: 'uj' (user joined) and the msg contains the username + const adminJoinMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg === federationConfig.hs1.adminMatrixUserId, + ); + + const hs1User1JoinMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg === federationConfig.hs1.additionalUser1.matrixUserId, + ); + + expect(adminJoinMessage).toBeDefined(); + expect(adminJoinMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(adminJoinMessage?.u?.username).toBe(federationConfig.hs1.adminMatrixUserId); + + expect(hs1User1JoinMessage).toBeDefined(); + expect(hs1User1JoinMessage?.msg).toContain(federationConfig.hs1.additionalUser1.matrixUserId); + expect(hs1User1JoinMessage?.u?.username).toBe(federationConfig.hs1.additionalUser1.matrixUserId); + }); + }); + + describe('Add 1 federated user and 1 local user in the creation modal', () => { + let channelName: string; + let federatedChannel: any; + + beforeAll(async () => { + channelName = `federated-channel-mixed-${Date.now()}`; + + // Create room with 1 federated user (from Synapse) and 1 local user (from RC) + const createResponse = await createRoom({ + type: 'p', + name: channelName, + members: [ + federationConfig.hs1.adminMatrixUserId, // federated user + federationConfig.rc1.additionalUser1.username, // local user + ], + extraData: { + federated: true, + }, + config: rc1AdminRequestConfig, + }); + + // For private groups, the response has 'group' property, not 'channel' + federatedChannel = createResponse.body.group; + + expect(federatedChannel).toHaveProperty('_id'); + expect(federatedChannel).toHaveProperty('name', channelName); + expect(federatedChannel).toHaveProperty('t', 'p'); + expect(federatedChannel).toHaveProperty('federated', true); + expect(federatedChannel).toHaveProperty('federation'); + expect((federatedChannel as any).federation).toHaveProperty('version', 1); + + // Accept invitation for the federated user (local user is already added automatically) + const acceptedRoomId = await hs1AdminApp.acceptInvitationForRoomName(channelName); + expect(acceptedRoomId).not.toBe(''); + }, 15000); + + it('It should show the room on the remote Element or RC and local for the second user', async () => { + // RC view: Check in RC (admin view) + const roomInfo = await getRoomInfo(federatedChannel._id, rc1AdminRequestConfig); + expect(roomInfo.room).toHaveProperty('_id', federatedChannel._id); + expect(roomInfo.room).toHaveProperty('federated', true); + + // RC view: Check in RC (user1 view - local user) + const roomInfoUser1 = await getRoomInfo(federatedChannel._id, rc1User1RequestConfig); + expect(roomInfoUser1.room).toHaveProperty('_id', federatedChannel._id); + expect(roomInfoUser1.room).toHaveProperty('federated', true); + + // Synapse view: Check in Synapse (Matrix) for federated user + const room = hs1AdminApp.getRoom(channelName); + expect(room).toHaveProperty('name', channelName); + expect(room.getMyMembership()).toBe('join'); + }); + + it('It should show the 2 new users in the members list', async () => { + // RC view: Check in RC (admin view) that both users are in the members list + const rc1AdminUserInRC = await findRoomMember(federatedChannel._id, federationConfig.rc1.adminUser, {}, rc1AdminRequestConfig); + const rc1User1InRC = await findRoomMember( + federatedChannel._id, + federationConfig.rc1.additionalUser1.username, + {}, + rc1AdminRequestConfig, + ); + const hs1AdminUserInRC = await findRoomMember( + federatedChannel._id, + federationConfig.hs1.adminMatrixUserId, + {}, + rc1AdminRequestConfig, + ); + + expect(rc1AdminUserInRC).not.toBeNull(); + expect(rc1User1InRC).not.toBeNull(); + expect(hs1AdminUserInRC).not.toBeNull(); + expect(hs1AdminUserInRC?.federated).toBe(true); + + // RC view: Check in RC (user1 view) that both users are in the members list + const rc1AdminUserInRCUser1 = await findRoomMember( + federatedChannel._id, + federationConfig.rc1.adminUser, + {}, + rc1User1RequestConfig, + ); + const rc1User1InRCUser1 = await findRoomMember( + federatedChannel._id, + federationConfig.rc1.additionalUser1.username, + {}, + rc1User1RequestConfig, + ); + const hs1AdminUserInRCUser1 = await findRoomMember( + federatedChannel._id, + federationConfig.hs1.adminMatrixUserId, + {}, + rc1User1RequestConfig, + ); + + expect(rc1AdminUserInRCUser1).not.toBeNull(); + expect(rc1User1InRCUser1).not.toBeNull(); + expect(hs1AdminUserInRCUser1).not.toBeNull(); + expect(hs1AdminUserInRCUser1?.federated).toBe(true); + + // Synapse view: Check in Synapse (Matrix) that both users are in the members list + const rc1AdminUserInSynapse = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.adminMatrixUserId, { + initialDelay: 2000, + }); + const rc1User1InSynapse = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.additionalUser1.matrixUserId, { + initialDelay: 2000, + }); + const hs1AdminUserInSynapse = await hs1AdminApp.findRoomMember(channelName, federationConfig.hs1.adminMatrixUserId, { + initialDelay: 2000, + }); + + expect(rc1AdminUserInSynapse).not.toBeNull(); + expect(rc1User1InSynapse).not.toBeNull(); + expect(hs1AdminUserInSynapse).not.toBeNull(); + }); + + it('It should show the 2 system messages that the user added', async () => { + // RC view: Check in RC (admin view) for system messages about both users joining + const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); + expect(Array.isArray(historyResponse.messages)).toBe(true); + + // Look for system messages about both users joining + const localUserJoinMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg === federationConfig.rc1.additionalUser1.username, + ); + + const federatedUserJoinMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg === federationConfig.hs1.adminMatrixUserId, + ); + + expect(localUserJoinMessage).toBeDefined(); + expect(localUserJoinMessage?.msg).toContain(federationConfig.rc1.additionalUser1.username); + expect(localUserJoinMessage?.u?.username).toBe(federationConfig.rc1.additionalUser1.username); + + expect(federatedUserJoinMessage).toBeDefined(); + expect(federatedUserJoinMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(federatedUserJoinMessage?.u?.username).toBe(federationConfig.hs1.adminMatrixUserId); + + // RC view: Check in RC (user1 view) for system messages about both users joining + const historyResponseUser1 = await getGroupHistory(federatedChannel._id, rc1User1RequestConfig); + expect(Array.isArray(historyResponseUser1.messages)).toBe(true); + + const localUserJoinMessageUser1 = historyResponseUser1.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg === federationConfig.rc1.additionalUser1.username, + ); + + const federatedUserJoinMessageUser1 = historyResponseUser1.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg === federationConfig.hs1.adminMatrixUserId, + ); + + expect(localUserJoinMessageUser1).toBeDefined(); + expect(federatedUserJoinMessageUser1).toBeDefined(); + }); + }); + }); + + describe('Create a room on RC as private and federated, then invite users', () => { + describe('Go to the members list and', () => { + describe('Add a federated user', () => { + let channelName: string; + let federatedChannel: any; + + beforeAll(async () => { + channelName = `federated-channel-invite-single-${Date.now()}`; + + // Create empty federated room without members + const createResponse = await createRoom({ + type: 'p', + name: channelName, + members: [], + extraData: { + federated: true, + }, + config: rc1AdminRequestConfig, + }); + + federatedChannel = createResponse.body.group; + + expect(federatedChannel).toHaveProperty('_id'); + expect(federatedChannel).toHaveProperty('name', channelName); + expect(federatedChannel).toHaveProperty('t', 'p'); + expect(federatedChannel).toHaveProperty('federated', true); + expect(federatedChannel).toHaveProperty('federation'); + expect((federatedChannel as any).federation).toHaveProperty('version', 1); + + // Wait for federation setup to complete (Matrix room creation and mrid assignment) + // This ensures the room.federation.mrid field is properly set before adding users + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Add federated user to the room + const addUserResponse = await addUserToRoom({ + usernames: [federationConfig.hs1.adminMatrixUserId], + rid: federatedChannel._id, + config: rc1AdminRequestConfig, + }); + + expect(addUserResponse.body).toHaveProperty('success', true); + + // Accept invitation for the federated user + const acceptedRoomId = await hs1AdminApp.acceptInvitationForRoomName(channelName); + expect(acceptedRoomId).not.toBe(''); + }, 15000); + + it('It should show the room on the remote Element or RC', async () => { + // RC view: Check in RC + const roomInfo = await getRoomInfo(federatedChannel._id, rc1AdminRequestConfig); + expect(roomInfo.room).toHaveProperty('_id', federatedChannel._id); + expect(roomInfo.room).toHaveProperty('federated', true); + + // Synapse view: Check in Element + const elementRoom = hs1AdminApp.getRoom(channelName); + expect(elementRoom).toHaveProperty('name', channelName); + }); + + it('It should show the new user in the members list', async () => { + // RC view: Check in RC that both users are in the members list + const rc1AdminUserInRC = await findRoomMember( + federatedChannel._id, + federationConfig.rc1.adminUser, + { initialDelay: 0 }, + rc1AdminRequestConfig, + ); + const hs1AdminUserInRC = await findRoomMember( + federatedChannel._id, + federationConfig.hs1.adminMatrixUserId, + { initialDelay: 0 }, + rc1AdminRequestConfig, + ); + + expect(rc1AdminUserInRC).not.toBeNull(); + expect(hs1AdminUserInRC).not.toBeNull(); + expect(hs1AdminUserInRC?.federated).toBe(true); + + // Synapse view: Check in Element (Matrix) that both users are in the members list + const rc1AdminUserInSynapse = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.adminMatrixUserId, { + delay: 2000, + }); + const hs1AdminUserInSynapse = await hs1AdminApp.findRoomMember(channelName, federationConfig.hs1.adminMatrixUserId, { + delay: 2000, + }); + expect(rc1AdminUserInSynapse).not.toBeNull(); + expect(hs1AdminUserInSynapse).not.toBeNull(); + }); + + it('It should show the system message that the user added', async () => { + // RC view: Check in RC + // Get the room history to find the system messages + const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); + expect(Array.isArray(historyResponse.messages)).toBe(true); + + // Look for system messages about the user being added + // look for 'au' (added user) message types + const addedMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + ); + + expect(addedMessage).toBeDefined(); + expect(addedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + }); + }); + + describe('Add 2 or more federated users at the same time', () => { + let channelName: string; + let federatedChannel: any; + + beforeAll(async () => { + channelName = `federated-channel-invite-multi-${Date.now()}`; + + // Create empty federated room without members + const createResponse = await createRoom({ + type: 'p', + name: channelName, + members: [], + extraData: { + federated: true, + }, + config: rc1AdminRequestConfig, + }); + + federatedChannel = createResponse.body.group; + + expect(federatedChannel).toHaveProperty('_id'); + expect(federatedChannel).toHaveProperty('name', channelName); + expect(federatedChannel).toHaveProperty('t', 'p'); + expect(federatedChannel).toHaveProperty('federated', true); + expect(federatedChannel).toHaveProperty('federation'); + expect((federatedChannel as any).federation).toHaveProperty('version', 1); + + // Wait for federation setup to complete (Matrix room creation and mrid assignment) + // This ensures the room.federation.mrid field is properly set before adding users + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Add both federated users to the room + const addUserResponse = await addUserToRoom({ + usernames: [federationConfig.hs1.adminMatrixUserId, federationConfig.hs1.additionalUser1.matrixUserId], + rid: federatedChannel._id, + config: rc1AdminRequestConfig, + }); + + expect(addUserResponse.body).toHaveProperty('success', true); + + // Accept invitations for both users + const acceptedRoomId1 = await hs1AdminApp.acceptInvitationForRoomName(channelName); + expect(acceptedRoomId1).not.toBe(''); + + const acceptedRoomId2 = await hs1User1App.acceptInvitationForRoomName(channelName); + expect(acceptedRoomId2).not.toBe(''); + }, 15000); + + it('It should show the room on all the involved remote Element or RC', async () => { + // RC view: Check in RC + const roomInfo = await getRoomInfo(federatedChannel._id, rc1AdminRequestConfig); + expect(roomInfo.room).toHaveProperty('_id', federatedChannel._id); + expect(roomInfo.room).toHaveProperty('federated', true); + + // Synapse view: Check in Element for admin user + const elementRoom1 = hs1AdminApp.getRoom(channelName); + expect(elementRoom1).toHaveProperty('name', channelName); + + // Synapse view: Check in Element for user1 + const elementRoom2 = hs1User1App.getRoom(channelName); + expect(elementRoom2).toHaveProperty('name', channelName); + }); + + it('It should show the new users in the members list of all RCs involved', async () => { + // RC view: Check in RC that all users are in the members list + const rc1AdminUserInRC = await findRoomMember(federatedChannel._id, federationConfig.rc1.adminUser, {}, rc1AdminRequestConfig); + const hs1AdminUserInRC = await findRoomMember( + federatedChannel._id, + federationConfig.hs1.adminMatrixUserId, + {}, + rc1AdminRequestConfig, + ); + const hs1User1InRC = await findRoomMember( + federatedChannel._id, + federationConfig.hs1.additionalUser1.matrixUserId, + {}, + rc1AdminRequestConfig, + ); + + expect(rc1AdminUserInRC).not.toBeNull(); + expect(hs1AdminUserInRC).not.toBeNull(); + expect(hs1User1InRC).not.toBeNull(); + expect(hs1AdminUserInRC?.federated).toBe(true); + expect(hs1User1InRC?.federated).toBe(true); + + // Synapse view: Check in Synapse (Matrix) for admin user that all users are in the members list + const rc1AdminUserInSynapseAdmin = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.adminMatrixUserId); + const hs1AdminUserInSynapseAdmin = await hs1AdminApp.findRoomMember(channelName, federationConfig.hs1.adminMatrixUserId); + const hs1User1InSynapseAdmin = await hs1AdminApp.findRoomMember( + channelName, + federationConfig.hs1.additionalUser1.matrixUserId, + { + initialDelay: 2000, + }, + ); + + expect(rc1AdminUserInSynapseAdmin).not.toBeNull(); + expect(hs1AdminUserInSynapseAdmin).not.toBeNull(); + expect(hs1User1InSynapseAdmin).not.toBeNull(); + + // Synapse view: Check in Synapse (Matrix) for additional user that all users are in the members list + const rc1AdminUserInSynapseUser1 = await hs1User1App.findRoomMember(channelName, federationConfig.rc1.adminMatrixUserId); + const hs1AdminUserInSynapseUser1 = await hs1User1App.findRoomMember(channelName, federationConfig.hs1.adminMatrixUserId); + const hs1User1InSynapseUser1 = await hs1User1App.findRoomMember(channelName, federationConfig.hs1.additionalUser1.matrixUserId); + + expect(rc1AdminUserInSynapseUser1).not.toBeNull(); + expect(hs1AdminUserInSynapseUser1).not.toBeNull(); + expect(hs1User1InSynapseUser1).not.toBeNull(); + }); + + it('It should show the system messages that the user added on all RCs involved', async () => { + // RC view: Check in RC + // Get the room history to find the system messages + const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); + expect(Array.isArray(historyResponse.messages)).toBe(true); + + // Look for system messages about both users being added + // 'au' (added user) message types + const adminAddedMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + ); + + expect(adminAddedMessage).toBeDefined(); + expect(adminAddedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + + // Look for 'au' (added user) message types + const hs1User1AddedMessage = historyResponse.messages.find( + (message: IMessage) => + message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.additionalUser1.matrixUserId), + ); + + expect(hs1User1AddedMessage).toBeDefined(); + expect(hs1User1AddedMessage?.msg).toContain(federationConfig.hs1.additionalUser1.matrixUserId); + }); + }); + + describe('Add 1 federated user and 1 local user at the same time', () => { + let channelName: string; + let federatedChannel: any; + + beforeAll(async () => { + channelName = `federated-channel-invite-mixed-${Date.now()}`; + + // Create empty federated room without members + const createResponse = await createRoom({ + type: 'p', + name: channelName, + members: [], + extraData: { + federated: true, + }, + config: rc1AdminRequestConfig, + }); + + federatedChannel = createResponse.body.group; + + expect(federatedChannel).toHaveProperty('_id'); + expect(federatedChannel).toHaveProperty('name', channelName); + expect(federatedChannel).toHaveProperty('t', 'p'); + expect(federatedChannel).toHaveProperty('federated', true); + expect(federatedChannel).toHaveProperty('federation'); + expect((federatedChannel as any).federation).toHaveProperty('version', 1); + + // Wait for federation setup to complete (Matrix room creation and mrid assignment) + // This ensures the room.federation.mrid field is properly set before adding users + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Add 1 federated user and 1 local user to the room + const addUserResponse = await addUserToRoom({ + usernames: [federationConfig.hs1.adminMatrixUserId, federationConfig.rc1.additionalUser1.username], + rid: federatedChannel._id, + config: rc1AdminRequestConfig, + }); + + expect(addUserResponse.body).toHaveProperty('success', true); + + // Accept invitation for the federated user (local user is added automatically) + const acceptedRoomId = await hs1AdminApp.acceptInvitationForRoomName(channelName); + expect(acceptedRoomId).not.toBe(''); + }, 15000); + + it('It should show the room on the remote Element or RC and local for the second user', async () => { + // RC view: Check in RC (admin view) + const roomInfo = await getRoomInfo(federatedChannel._id, rc1AdminRequestConfig); + expect(roomInfo.room).toHaveProperty('_id', federatedChannel._id); + expect(roomInfo.room).toHaveProperty('federated', true); + + // RC view: Check in RC (user1 view - local user) + const roomInfoUser1 = await getRoomInfo(federatedChannel._id, rc1User1RequestConfig); + expect(roomInfoUser1.room).toHaveProperty('_id', federatedChannel._id); + expect(roomInfoUser1.room).toHaveProperty('federated', true); + + // Synapse view: Check in Synapse (Matrix) for federated user + const room = hs1AdminApp.getRoom(channelName); + expect(room).toHaveProperty('name', channelName); + expect(room.getMyMembership()).toBe('join'); + }); + + it('It should show the 2 new users in the members list', async () => { + // RC view: Check in RC (admin view) that all users are in the members list + const rc1AdminUserInRC = await findRoomMember(federatedChannel._id, federationConfig.rc1.adminUser, {}, rc1AdminRequestConfig); + const rc1User1InRC = await findRoomMember( + federatedChannel._id, + federationConfig.rc1.additionalUser1.username, + {}, + rc1AdminRequestConfig, + ); + const hs1AdminUserInRC = await findRoomMember( + federatedChannel._id, + federationConfig.hs1.adminMatrixUserId, + {}, + rc1AdminRequestConfig, + ); + + expect(rc1AdminUserInRC).not.toBeNull(); + expect(rc1User1InRC).not.toBeNull(); + expect(hs1AdminUserInRC).not.toBeNull(); + expect(hs1AdminUserInRC?.federated).toBe(true); + + // RC view: Check in RC (user1 view) that all users are in the members list + const rc1AdminUserInRCUser1 = await findRoomMember( + federatedChannel._id, + federationConfig.rc1.adminUser, + {}, + rc1User1RequestConfig, + ); + const rc1User1InRCUser1 = await findRoomMember( + federatedChannel._id, + federationConfig.rc1.additionalUser1.username, + {}, + rc1User1RequestConfig, + ); + const hs1AdminUserInRCUser1 = await findRoomMember( + federatedChannel._id, + federationConfig.hs1.adminMatrixUserId, + {}, + rc1User1RequestConfig, + ); + + expect(rc1AdminUserInRCUser1).not.toBeNull(); + expect(rc1User1InRCUser1).not.toBeNull(); + expect(hs1AdminUserInRCUser1).not.toBeNull(); + expect(hs1AdminUserInRCUser1?.federated).toBe(true); + + // Synapse view: Check in Synapse (Matrix) that all users are in the members list + const rc1AdminUserInSynapse = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.adminMatrixUserId, { + initialDelay: 2000, + }); + const rc1User1InSynapse = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.additionalUser1.matrixUserId, { + initialDelay: 2000, + }); + const hs1AdminUserInSynapse = await hs1AdminApp.findRoomMember(channelName, federationConfig.hs1.adminMatrixUserId, { + initialDelay: 2000, + }); + + expect(rc1AdminUserInSynapse).not.toBeNull(); + expect(rc1User1InSynapse).not.toBeNull(); + expect(hs1AdminUserInSynapse).not.toBeNull(); + }); + + it('It should show the 2 system messages that the user added', async () => { + // RC view: Check in RC (admin view) for system messages about both users joining + const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); + expect(Array.isArray(historyResponse.messages)).toBe(true); + + // 'au' (added user) message types + const localUserAddedMessage = historyResponse.messages.find( + (message: IMessage) => + message.t === 'au' && message.msg && message.msg.includes(federationConfig.rc1.additionalUser1.username), + ); + + expect(localUserAddedMessage).toBeDefined(); + expect(localUserAddedMessage?.msg).toContain(federationConfig.rc1.additionalUser1.username); + + const federatedUserAddedMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + ); + + expect(federatedUserAddedMessage).toBeDefined(); + expect(federatedUserAddedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + + // RC view: Check in RC (user1 view) for system messages about both users being added + const historyResponseUser1 = await getGroupHistory(federatedChannel._id, rc1User1RequestConfig); + expect(Array.isArray(historyResponseUser1.messages)).toBe(true); + + // Look for 'au' (added user) message types + const localUserAddedMessageUser1 = historyResponseUser1.messages.find( + (message: IMessage) => + message.t === 'au' && message.msg && message.msg.includes(federationConfig.rc1.additionalUser1.username), + ); + + expect(localUserAddedMessageUser1).toBeDefined(); + expect(localUserAddedMessageUser1?.msg).toContain(federationConfig.rc1.additionalUser1.username); + + const federatedUserAddedMessageUser1 = historyResponseUser1.messages.find( + (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + ); + + expect(federatedUserAddedMessageUser1).toBeDefined(); + expect(federatedUserAddedMessageUser1?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + }); + }); + }); + + describe('Go to the composer and use the /invite slash command to', () => { + describe('Add a federated user', () => { + let channelName: string; + let federatedChannel: any; + + beforeAll(async () => { + channelName = `federated-channel-slash-single-${Date.now()}`; + + // Create empty federated room without members + const createResponse = await createRoom({ + type: 'p', + name: channelName, + members: [], + extraData: { + federated: true, + }, + config: rc1AdminRequestConfig, + }); + + federatedChannel = createResponse.body.group; + + expect(federatedChannel).toHaveProperty('_id'); + expect(federatedChannel).toHaveProperty('name', channelName); + expect(federatedChannel).toHaveProperty('t', 'p'); + expect(federatedChannel).toHaveProperty('federated', true); + expect(federatedChannel).toHaveProperty('federation'); + expect((federatedChannel as any).federation).toHaveProperty('version', 1); + + // Wait for federation setup to complete (Matrix room creation and mrid assignment) + // This ensures the room.federation.mrid field is properly set before adding users + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Add federated user to the room using slash command + const addUserResponse = await addUserToRoomSlashCommand({ + usernames: [federationConfig.hs1.adminMatrixUserId], + rid: federatedChannel._id, + config: rc1AdminRequestConfig, + }); + + expect(addUserResponse.body).toHaveProperty('success', true); + + // Accept invitation for the federated user + const acceptedRoomId = await hs1AdminApp.acceptInvitationForRoomName(channelName); + expect(acceptedRoomId).not.toBe(''); + }, 15000); + + it('It should show the room on the remote Element or RC', async () => { + // RC view: Check in RC + const roomInfo = await getRoomInfo(federatedChannel._id, rc1AdminRequestConfig); + expect(roomInfo.room).toHaveProperty('_id', federatedChannel._id); + expect(roomInfo.room).toHaveProperty('federated', true); + + // Synapse view: Check in Element + const elementRoom = hs1AdminApp.getRoom(channelName); + expect(elementRoom).toHaveProperty('name', channelName); + }); + + it('It should show the new user in the members list', async () => { + // RC view: Check in RC that both users are in the members list + const rc1AdminUserInRC = await findRoomMember( + federatedChannel._id, + federationConfig.rc1.adminUser, + { initialDelay: 0 }, + rc1AdminRequestConfig, + ); + const hs1AdminUserInRC = await findRoomMember( + federatedChannel._id, + federationConfig.hs1.adminMatrixUserId, + { initialDelay: 0 }, + rc1AdminRequestConfig, + ); + + expect(rc1AdminUserInRC).not.toBeNull(); + expect(hs1AdminUserInRC).not.toBeNull(); + expect(hs1AdminUserInRC?.federated).toBe(true); + + // Synapse view: Check in Element (Matrix) that both users are in the members list + const rc1AdminUserInSynapse = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.adminMatrixUserId, { + delay: 2000, + }); + const hs1AdminUserInSynapse = await hs1AdminApp.findRoomMember(channelName, federationConfig.hs1.adminMatrixUserId, { + delay: 2000, + }); + expect(rc1AdminUserInSynapse).not.toBeNull(); + expect(hs1AdminUserInSynapse).not.toBeNull(); + }); + + it('It should show the system message that the user added', async () => { + // RC view: Check in RC + // Get the room history to find the system messages + const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); + expect(Array.isArray(historyResponse.messages)).toBe(true); + + // Look for system messages about the user being added + // 'au' (added user) message types + const addedMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + ); + + expect(addedMessage).toBeDefined(); + expect(addedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + }); + }); + + describe('Add 2 or more federated users at the same time', () => { + let channelName: string; + let federatedChannel: any; + + beforeAll(async () => { + channelName = `federated-channel-slash-multi-${Date.now()}`; + + // Create empty federated room without members + const createResponse = await createRoom({ + type: 'p', + name: channelName, + members: [], + extraData: { + federated: true, + }, + config: rc1AdminRequestConfig, + }); + + federatedChannel = createResponse.body.group; + + expect(federatedChannel).toHaveProperty('_id'); + expect(federatedChannel).toHaveProperty('name', channelName); + expect(federatedChannel).toHaveProperty('t', 'p'); + expect(federatedChannel).toHaveProperty('federated', true); + expect(federatedChannel).toHaveProperty('federation'); + expect((federatedChannel as any).federation).toHaveProperty('version', 1); + + // Wait for federation setup to complete (Matrix room creation and mrid assignment) + // This ensures the room.federation.mrid field is properly set before adding users + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Add both federated users to the room using slash command + const addUserResponse = await addUserToRoomSlashCommand({ + usernames: [federationConfig.hs1.adminMatrixUserId, federationConfig.hs1.additionalUser1.matrixUserId], + rid: federatedChannel._id, + config: rc1AdminRequestConfig, + }); + + expect(addUserResponse.body).toHaveProperty('success', true); + + // Accept invitations for both users + const acceptedRoomId1 = await hs1AdminApp.acceptInvitationForRoomName(channelName); + expect(acceptedRoomId1).not.toBe(''); + + const acceptedRoomId2 = await hs1User1App.acceptInvitationForRoomName(channelName); + expect(acceptedRoomId2).not.toBe(''); + }, 15000); + + it('It should show the room on all the involved remote Element or RC', async () => { + // RC view: Check in RC + const roomInfo = await getRoomInfo(federatedChannel._id, rc1AdminRequestConfig); + expect(roomInfo.room).toHaveProperty('_id', federatedChannel._id); + expect(roomInfo.room).toHaveProperty('federated', true); + + // Synapse view: Check in Element for admin user + const elementRoom1 = hs1AdminApp.getRoom(channelName); + expect(elementRoom1).toHaveProperty('name', channelName); + + // Synapse view: Check in Element for user1 + const elementRoom2 = hs1User1App.getRoom(channelName); + expect(elementRoom2).toHaveProperty('name', channelName); + }); + + it('It should show the new users in the members list of all RCs involved', async () => { + // RC view: Check in RC that all users are in the members list + const rc1AdminUserInRC = await findRoomMember(federatedChannel._id, federationConfig.rc1.adminUser, {}, rc1AdminRequestConfig); + const hs1AdminUserInRC = await findRoomMember( + federatedChannel._id, + federationConfig.hs1.adminMatrixUserId, + {}, + rc1AdminRequestConfig, + ); + const hs1User1InRC = await findRoomMember( + federatedChannel._id, + federationConfig.hs1.additionalUser1.matrixUserId, + {}, + rc1AdminRequestConfig, + ); + + expect(rc1AdminUserInRC).not.toBeNull(); + expect(hs1AdminUserInRC).not.toBeNull(); + expect(hs1User1InRC).not.toBeNull(); + expect(hs1AdminUserInRC?.federated).toBe(true); + expect(hs1User1InRC?.federated).toBe(true); + + // Synapse view: Check in Synapse (Matrix) for admin user that all users are in the members list + const rc1AdminUserInSynapseAdmin = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.adminMatrixUserId); + const hs1AdminUserInSynapseAdmin = await hs1AdminApp.findRoomMember(channelName, federationConfig.hs1.adminMatrixUserId); + const hs1User1InSynapseAdmin = await hs1AdminApp.findRoomMember( + channelName, + federationConfig.hs1.additionalUser1.matrixUserId, + { + initialDelay: 2000, + }, + ); + + expect(rc1AdminUserInSynapseAdmin).not.toBeNull(); + expect(hs1AdminUserInSynapseAdmin).not.toBeNull(); + expect(hs1User1InSynapseAdmin).not.toBeNull(); + + // Synapse view: Check in Synapse (Matrix) for additional user that all users are in the members list + const rc1AdminUserInSynapseUser1 = await hs1User1App.findRoomMember(channelName, federationConfig.rc1.adminMatrixUserId); + const hs1AdminUserInSynapseUser1 = await hs1User1App.findRoomMember(channelName, federationConfig.hs1.adminMatrixUserId); + const hs1User1InSynapseUser1 = await hs1User1App.findRoomMember(channelName, federationConfig.hs1.additionalUser1.matrixUserId); + + expect(rc1AdminUserInSynapseUser1).not.toBeNull(); + expect(hs1AdminUserInSynapseUser1).not.toBeNull(); + expect(hs1User1InSynapseUser1).not.toBeNull(); + }); + + it('It should show the system messages that the user added on all RCs involved', async () => { + // RC view: Check in RC + // Get the room history to find the system messages + const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); + expect(Array.isArray(historyResponse.messages)).toBe(true); + + // Look for system messages about both users being added + // 'au' (added user) message types + const adminAddedMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + ); + + expect(adminAddedMessage).toBeDefined(); + expect(adminAddedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + + const hs1User1AddedMessage = historyResponse.messages.find( + (message: IMessage) => + message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.additionalUser1.matrixUserId), + ); + + expect(hs1User1AddedMessage).toBeDefined(); + expect(hs1User1AddedMessage?.msg).toContain(federationConfig.hs1.additionalUser1.matrixUserId); + }); + }); + + describe('Add 1 federated user and 1 local user at the same time', () => { + let channelName: string; + let federatedChannel: any; + + beforeAll(async () => { + channelName = `federated-channel-slash-mixed-${Date.now()}`; + + // Create empty federated room without members + const createResponse = await createRoom({ + type: 'p', + name: channelName, + members: [], + extraData: { + federated: true, + }, + config: rc1AdminRequestConfig, + }); + + federatedChannel = createResponse.body.group; + + expect(federatedChannel).toHaveProperty('_id'); + expect(federatedChannel).toHaveProperty('name', channelName); + expect(federatedChannel).toHaveProperty('t', 'p'); + expect(federatedChannel).toHaveProperty('federated', true); + expect(federatedChannel).toHaveProperty('federation'); + expect((federatedChannel as any).federation).toHaveProperty('version', 1); + + // Wait for federation setup to complete (Matrix room creation and mrid assignment) + // This ensures the room.federation.mrid field is properly set before adding users + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Add 1 federated user and 1 local user to the room using slash command + const addUserResponse = await addUserToRoomSlashCommand({ + usernames: [federationConfig.hs1.adminMatrixUserId, federationConfig.rc1.additionalUser1.username], + rid: federatedChannel._id, + config: rc1AdminRequestConfig, + }); + + expect(addUserResponse.body).toHaveProperty('success', true); + + // Accept invitation for the federated user (local user is added automatically) + const acceptedRoomId = await hs1AdminApp.acceptInvitationForRoomName(channelName); + expect(acceptedRoomId).not.toBe(''); + }, 15000); + + it('It should show the room on the remote Element or RC and local for the second user', async () => { + // RC view: Check in RC (admin view) + const roomInfo = await getRoomInfo(federatedChannel._id, rc1AdminRequestConfig); + expect(roomInfo.room).toHaveProperty('_id', federatedChannel._id); + expect(roomInfo.room).toHaveProperty('federated', true); + + // RC view: Check in RC (user1 view - local user) + const roomInfoUser1 = await getRoomInfo(federatedChannel._id, rc1User1RequestConfig); + expect(roomInfoUser1.room).toHaveProperty('_id', federatedChannel._id); + expect(roomInfoUser1.room).toHaveProperty('federated', true); + + // Synapse view: Check in Synapse (Matrix) for federated user + const room = hs1AdminApp.getRoom(channelName); + expect(room).toHaveProperty('name', channelName); + expect(room.getMyMembership()).toBe('join'); + }); + + it('It should show the 2 new users in the members list', async () => { + // RC view: Check in RC (admin view) that all users are in the members list + const rc1AdminUserInRC = await findRoomMember(federatedChannel._id, federationConfig.rc1.adminUser, {}, rc1AdminRequestConfig); + const rc1User1InRC = await findRoomMember( + federatedChannel._id, + federationConfig.rc1.additionalUser1.username, + {}, + rc1AdminRequestConfig, + ); + const hs1AdminUserInRC = await findRoomMember( + federatedChannel._id, + federationConfig.hs1.adminMatrixUserId, + {}, + rc1AdminRequestConfig, + ); + + expect(rc1AdminUserInRC).not.toBeNull(); + expect(rc1User1InRC).not.toBeNull(); + expect(hs1AdminUserInRC).not.toBeNull(); + expect(hs1AdminUserInRC?.federated).toBe(true); + + // RC view: Check in RC (user1 view) that all users are in the members list + const rc1AdminUserInRCUser1 = await findRoomMember( + federatedChannel._id, + federationConfig.rc1.adminUser, + {}, + rc1User1RequestConfig, + ); + const rc1User1InRCUser1 = await findRoomMember( + federatedChannel._id, + federationConfig.rc1.additionalUser1.username, + {}, + rc1User1RequestConfig, + ); + const hs1AdminUserInRCUser1 = await findRoomMember( + federatedChannel._id, + federationConfig.hs1.adminMatrixUserId, + {}, + rc1User1RequestConfig, + ); + + expect(rc1AdminUserInRCUser1).not.toBeNull(); + expect(rc1User1InRCUser1).not.toBeNull(); + expect(hs1AdminUserInRCUser1).not.toBeNull(); + expect(hs1AdminUserInRCUser1?.federated).toBe(true); + + // Synapse view: Check in Synapse (Matrix) that all users are in the members list + const rc1AdminUserInSynapse = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.adminMatrixUserId, { + initialDelay: 2000, + }); + const rc1User1InSynapse = await hs1AdminApp.findRoomMember(channelName, federationConfig.rc1.additionalUser1.matrixUserId, { + initialDelay: 2000, + }); + const hs1AdminUserInSynapse = await hs1AdminApp.findRoomMember(channelName, federationConfig.hs1.adminMatrixUserId, { + initialDelay: 2000, + }); + + expect(rc1AdminUserInSynapse).not.toBeNull(); + expect(rc1User1InSynapse).not.toBeNull(); + expect(hs1AdminUserInSynapse).not.toBeNull(); + }); + + it('It should show the 2 system messages that the user added', async () => { + // RC view: Check in RC (admin view) for system messages about both users joining + const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); + expect(Array.isArray(historyResponse.messages)).toBe(true); + + // Look for system messages about both users joining + // 'au' (added user) message types + const localUserAddedMessage = historyResponse.messages.find( + (message: IMessage) => + message.t === 'au' && message.msg && message.msg.includes(federationConfig.rc1.additionalUser1.username), + ); + + expect(localUserAddedMessage).toBeDefined(); + expect(localUserAddedMessage?.msg).toContain(federationConfig.rc1.additionalUser1.username); + + const federatedUserAddedMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + ); + + expect(federatedUserAddedMessage).toBeDefined(); + expect(federatedUserAddedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + + // RC view: Check in RC (user1 view) for system messages about both users being added + const historyResponseUser1 = await getGroupHistory(federatedChannel._id, rc1User1RequestConfig); + expect(Array.isArray(historyResponseUser1.messages)).toBe(true); + + // Look for 'au' (added user) message types + const localUserAddedMessageUser1 = historyResponseUser1.messages.find( + (message: IMessage) => + message.t === 'au' && message.msg && message.msg.includes(federationConfig.rc1.additionalUser1.username), + ); + + const federatedUserAddedMessageUser1 = historyResponseUser1.messages.find( + (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + ); + + expect(localUserAddedMessageUser1).toBeDefined(); + expect(localUserAddedMessageUser1?.msg).toContain(federationConfig.rc1.additionalUser1.username); + + expect(federatedUserAddedMessageUser1).toBeDefined(); + expect(federatedUserAddedMessageUser1?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + }); + }); + }); + }); + }); +}); diff --git a/ee/packages/federation-matrix/tests/helper/config.ts b/ee/packages/federation-matrix/tests/helper/config.ts new file mode 100644 index 0000000000000..7ed86593c873d --- /dev/null +++ b/ee/packages/federation-matrix/tests/helper/config.ts @@ -0,0 +1,127 @@ +/** + * 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. + */ +export interface IFederationConfig { + rc1: { + apiUrl: string; + adminUser: string; + adminPassword: string; + adminMatrixUserId: string; + additionalUser1: { + username: string; + password: string; + matrixUserId: string; + }; + }; + hs1: { + url: string; + adminMatrixUserId: string; + password: string; + homeserver: string; + adminUser: string; + adminPassword: string; + additionalUser1: { + username: string; + password: string; + matrixUserId: string; + }; + }; +} + +/** + * 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 value - The environment variable value (may be undefined) + * @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, value: string | undefined, defaultValue?: string): string { + const finalValue = value || 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 { + return { + rc1: { + apiUrl: validateEnvVar('FEDERATION_RC1_API_URL', process.env.FEDERATION_RC1_API_URL, 'https://rc1'), + adminUser: validateEnvVar('FEDERATION_RC1_ADMIN_USER', process.env.FEDERATION_RC1_ADMIN_USER, 'admin'), + adminPassword: validateEnvVar('FEDERATION_RC1_ADMIN_PASSWORD', process.env.FEDERATION_RC1_ADMIN_PASSWORD, 'admin'), + adminMatrixUserId: validateEnvVar('FEDERATION_RC1_USER_ID', process.env.FEDERATION_RC1_USER_ID, '@admin:rc1'), + additionalUser1: { + username: validateEnvVar('FEDERATION_RC1_ADDITIONAL_USER1', process.env.FEDERATION_RC1_ADDITIONAL_USER1, 'user2'), + password: validateEnvVar( + 'FEDERATION_RC1_ADDITIONAL_USER1_PASSWORD', + process.env.FEDERATION_RC1_ADDITIONAL_USER1_PASSWORD, + 'user2pass', + ), + matrixUserId: validateEnvVar( + 'FEDERATION_RC1_ADDITIONAL_USER1_MATRIX_ID', + process.env.FEDERATION_RC1_ADDITIONAL_USER1_MATRIX_ID, + '@user2:rc1', + ), + }, + }, + hs1: { + url: validateEnvVar('FEDERATION_SYNAPSE_URL', process.env.FEDERATION_SYNAPSE_URL, 'https://hs1'), + adminMatrixUserId: validateEnvVar('FEDERATION_SYNAPSE_USER', process.env.FEDERATION_SYNAPSE_USER, '@admin:hs1'), + password: validateEnvVar('FEDERATION_SYNAPSE_PASSWORD', process.env.FEDERATION_SYNAPSE_PASSWORD, 'admin'), + homeserver: validateEnvVar('FEDERATION_SYNAPSE_HOMESERVER', process.env.FEDERATION_SYNAPSE_HOMESERVER, 'hs1'), + adminUser: validateEnvVar('FEDERATION_SYNAPSE_ADMIN_USER', process.env.FEDERATION_SYNAPSE_ADMIN_USER, 'admin'), + adminPassword: validateEnvVar('FEDERATION_SYNAPSE_ADMIN_PASSWORD', process.env.FEDERATION_SYNAPSE_ADMIN_PASSWORD, 'admin'), + additionalUser1: { + username: validateEnvVar('FEDERATION_SYNAPSE_ADDITIONAL_USER1', process.env.FEDERATION_SYNAPSE_ADDITIONAL_USER1, 'alice'), + password: validateEnvVar( + 'FEDERATION_SYNAPSE_ADDITIONAL_USER1_PASSWORD', + process.env.FEDERATION_SYNAPSE_ADDITIONAL_USER1_PASSWORD, + 'alice', + ), + matrixUserId: validateEnvVar( + 'FEDERATION_SYNAPSE_ADDITIONAL_USER1_MATRIX_ID', + process.env.FEDERATION_SYNAPSE_ADDITIONAL_USER1_MATRIX_ID, + '@alice:hs1', + ), + }, + }, + }; +} + +/** + * 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 }; diff --git a/ee/packages/federation-matrix/tests/helper/ddp-listener.ts b/ee/packages/federation-matrix/tests/helper/ddp-listener.ts new file mode 100644 index 0000000000000..c71930c80b015 --- /dev/null +++ b/ee/packages/federation-matrix/tests/helper/ddp-listener.ts @@ -0,0 +1,170 @@ +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 { + 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 { + return new Promise((resolve, reject) => { + // Check if message already exists + const existingMessage = this.ephemeralMessages.find((msg) => { + console.log('msg', 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) => { + console.log('msg', 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); +} diff --git a/ee/packages/federation-matrix/tests/helper/synapse-client.ts b/ee/packages/federation-matrix/tests/helper/synapse-client.ts new file mode 100644 index 0000000000000..63268e14c7170 --- /dev/null +++ b/ee/packages/federation-matrix/tests/helper/synapse-client.ts @@ -0,0 +1,421 @@ +/* eslint-disable no-await-in-loop */ +/** + * Federation test data and configuration + * This file provides validated federation configuration for federation tests. + */ + +import { createClient, type MatrixClient, KnownMembership, type Room, type RoomMember } from 'matrix-js-sdk'; + +/** + * 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 { + 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; + } + + /** + * 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 { + 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 { + 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}`); + } + + /** + * 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } + + /** + * 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 { + 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; + } + } +} diff --git a/ee/packages/federation-matrix/tests/scripts/run-integration-tests.sh b/ee/packages/federation-matrix/tests/scripts/run-integration-tests.sh new file mode 100755 index 0000000000000..9f0351fc858b9 --- /dev/null +++ b/ee/packages/federation-matrix/tests/scripts/run-integration-tests.sh @@ -0,0 +1,347 @@ +#!/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 +RC1_CONTAINER="rc1" + +# 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 +PROFILE_PREFIX="local" # Default to local build +NO_TEST=false + +while [[ $# -gt 0 ]]; do + case $1 in + --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 " --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}" +} + +# Cleanup function +cleanup() { + # 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 "==========================================" + + echo "" + echo "ROCKET.CHAT (rc1) LOGS:" + echo "----------------------------------------" + if docker ps -q -f name=rc1 | grep -q .; then + docker logs rc1 2>&1 | sed 's/^/ /' + else + echo " Rocket.Chat container not found or no logs" + fi + + echo "" + echo "SYNAPSE (hs1) LOGS:" + echo "----------------------------------------" + if docker ps -q -f name=hs1 | grep -q .; then + docker logs hs1 2>&1 | sed 's/^/ /' + else + echo " Synapse container not found or no logs" + fi + + echo "" + echo "==========================================" + 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 + if [ "$INCLUDE_ELEMENT" = true ]; then + log_info "To stop containers manually, run: docker compose -f $DOCKER_COMPOSE_FILE --profile element-$PROFILE_PREFIX down -v" + else + log_info "To stop containers manually, run: docker compose -f $DOCKER_COMPOSE_FILE --profile test-$PROFILE_PREFIX down -v" + fi + else + log_info "Cleaning up services..." + if [ -f "$DOCKER_COMPOSE_FILE" ]; then + if [ "$INCLUDE_ELEMENT" = true ]; then + docker compose -f "$DOCKER_COMPOSE_FILE" --profile "element-$PROFILE_PREFIX" down -v 2>/dev/null || true + else + docker compose -f "$DOCKER_COMPOSE_FILE" --profile "test-$PROFILE_PREFIX" down -v 2>/dev/null || true + fi + fi + 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 + +# 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 "=====================================" + +# Set environment variables for Docker Compose +if [ "$USE_PREBUILT_IMAGE" = true ]; then + export ROCKETCHAT_IMAGE="$PREBUILT_IMAGE" + PROFILE_PREFIX="prebuilt" + 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" + PROFILE_PREFIX="local" + log_info "Building from local context: $BUILD_DIR" +fi + +# Start services +if [ "$INCLUDE_ELEMENT" = true ]; then + PROFILE="element-$PROFILE_PREFIX" + log_info "Starting all federation services including Element web client..." + docker compose -f "$DOCKER_COMPOSE_FILE" --profile "$PROFILE" up -d --build +else + PROFILE="test-$PROFILE_PREFIX" + log_info "Starting federation services (test profile only)..." + docker compose -f "$DOCKER_COMPOSE_FILE" --profile "$PROFILE" up -d --build +fi + +# 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 ps --filter "name=$RC1_CONTAINER" --filter "status=running" | grep -q "$RC1_CONTAINER"; 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 logs --tail 50 "$RC1_CONTAINER" 2>&1 | sed 's/^/ /' + 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 logs --tail 50 "hs1" 2>&1 | sed 's/^/ /' + exit 1 +fi + +# Run the end-to-end tests +if [ "$NO_TEST" = false ]; then + log_info "Running end-to-end tests..." + cd "$PACKAGE_ROOT" + + yarn testend-to-end + TEST_EXIT_CODE=$? +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 diff --git a/ee/packages/federation-matrix/tests/teardown.ts b/ee/packages/federation-matrix/tests/teardown.ts new file mode 100644 index 0000000000000..de3e156162643 --- /dev/null +++ b/ee/packages/federation-matrix/tests/teardown.ts @@ -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)); +} diff --git a/yarn.lock b/yarn.lock index b15700276c712..68391c1a81149 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4811,6 +4811,13 @@ __metadata: languageName: node linkType: hard +"@matrix-org/matrix-sdk-crypto-wasm@npm:^15.3.0": + version: 15.3.0 + resolution: "@matrix-org/matrix-sdk-crypto-wasm@npm:15.3.0" + checksum: 10/cbc89a80c7e3e81ce3de6d90b87862a81292a0abe1a8588c953191128b8e37e2049b975a19f80a53c8fcb8b875467f2d0c7e44fa5a7b7ffbb7a95a04a2d168e8 + languageName: node + linkType: hard + "@mdx-js/react@npm:^3.0.0": version: 3.0.1 resolution: "@mdx-js/react@npm:3.0.1" @@ -8408,6 +8415,7 @@ __metadata: "@babel/preset-typescript": "npm:~7.27.1" "@rocket.chat/core-services": "workspace:^" "@rocket.chat/core-typings": "workspace:^" + "@rocket.chat/ddp-client": "workspace:^" "@rocket.chat/emitter": "npm:^0.31.25" "@rocket.chat/eslint-config": "workspace:^" "@rocket.chat/federation-sdk": "npm:0.3.0" @@ -8424,6 +8432,7 @@ __metadata: eslint: "npm:~8.45.0" jest: "npm:~30.2.0" marked: "npm:^16.1.2" + matrix-js-sdk: "npm:^38.4.0" mongodb: "npm:6.16.0" pino: "npm:^8.21.0" pino-pretty: "npm:^7.6.1" @@ -13298,6 +13307,13 @@ __metadata: languageName: node linkType: hard +"@types/events@npm:^3.0.0": + version: 3.0.3 + resolution: "@types/events@npm:3.0.3" + checksum: 10/50af9312fab001fd6bd4bb3ff65830f940877e6778de140a92481a0d9bf5f4853d44ec758a8800ef60e0598ac43ed1b5688116a3c65906ae54e989278d6c7c82 + languageName: node + linkType: hard + "@types/express-rate-limit@npm:^5.1.3": version: 5.1.3 resolution: "@types/express-rate-limit@npm:5.1.3" @@ -15501,6 +15517,13 @@ __metadata: languageName: node linkType: hard +"another-json@npm:^0.2.0": + version: 0.2.0 + resolution: "another-json@npm:0.2.0" + checksum: 10/2b1ad49eaea26d89baf2b3a1d9bd882bd38d44ba7520412698708cb5307b724e792210109dd6dd41a4d1512d99034ce160c2f99aeb668177da1638981ddce97f + languageName: node + linkType: hard + "ansi-color@npm:^0.2.1": version: 0.2.1 resolution: "ansi-color@npm:0.2.1" @@ -16620,6 +16643,13 @@ __metadata: languageName: node linkType: hard +"base-x@npm:^5.0.0": + version: 5.0.1 + resolution: "base-x@npm:5.0.1" + checksum: 10/6e4f847ef842e0a71c6b6020a6ec482a2a5e727f5a98534dbfd5d5a4e8afbc0d1bdf1fd57174b3f0455d107f10a932c3c7710bec07e2878f80178607f8f605c8 + languageName: node + linkType: hard + "base32.js@npm:0.0.1": version: 0.0.1 resolution: "base32.js@npm:0.0.1" @@ -17071,6 +17101,15 @@ __metadata: languageName: node linkType: hard +"bs58@npm:^6.0.0": + version: 6.0.0 + resolution: "bs58@npm:6.0.0" + dependencies: + base-x: "npm:^5.0.0" + checksum: 10/7c9bb2b2d93d997a8c652de3510d89772007ac64ee913dc4e16ba7ff47624caad3128dcc7f360763eb6308760c300b3e9fd91b8bcbd489acd1a13278e7949c4e + languageName: node + linkType: hard + "bser@npm:2.1.1": version: 2.1.1 resolution: "bser@npm:2.1.1" @@ -18322,7 +18361,7 @@ __metadata: languageName: node linkType: hard -"content-type@npm:^1.0.5, content-type@npm:~1.0.4, content-type@npm:~1.0.5": +"content-type@npm:^1.0.4, content-type@npm:^1.0.5, content-type@npm:~1.0.4, content-type@npm:~1.0.5": version: 1.0.5 resolution: "content-type@npm:1.0.5" checksum: 10/585847d98dc7fb8035c02ae2cb76c7a9bd7b25f84c447e5ed55c45c2175e83617c8813871b4ee22f368126af6b2b167df655829007b21aa10302873ea9c62662 @@ -24359,6 +24398,13 @@ __metadata: languageName: node linkType: hard +"is-network-error@npm:^1.1.0": + version: 1.3.0 + resolution: "is-network-error@npm:1.3.0" + checksum: 10/56dc0b8ed9c0bb72202058f172ad0c3121cf68772e8cbba343d3775f6e2ec7877d423cbcea45f4cedcd345de8693de1b52dfe0c6fc15d652c4aa98c2abf0185a + languageName: node + linkType: hard + "is-number-object@npm:^1.1.1": version: 1.1.1 resolution: "is-number-object@npm:1.1.1" @@ -26369,6 +26415,13 @@ __metadata: languageName: node linkType: hard +"jwt-decode@npm:^4.0.0": + version: 4.0.0 + resolution: "jwt-decode@npm:4.0.0" + checksum: 10/87b569e4a9a0067fb0d592bcf3b2ac3e638e49beee28620eeb07bef1b4470f4077dea68c15d191dd68e076846c3af8394be3bcaecffedc6e97433b221fdbbcf3 + languageName: node + linkType: hard + "katex@npm:~0.16.22": version: 0.16.22 resolution: "katex@npm:0.16.22" @@ -27225,6 +27278,45 @@ __metadata: languageName: node linkType: hard +"matrix-events-sdk@npm:0.0.1": + version: 0.0.1 + resolution: "matrix-events-sdk@npm:0.0.1" + checksum: 10/967fd059278e7d7299436829bd66cc8d7d49ddc48d42a6d49a6cc16ba9e429c8ac6388d8d5e3629fd01161732910591773780e7278dd3154931d266a3478ff9c + languageName: node + linkType: hard + +"matrix-js-sdk@npm:^38.4.0": + version: 38.4.0 + resolution: "matrix-js-sdk@npm:38.4.0" + dependencies: + "@babel/runtime": "npm:^7.12.5" + "@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.3.0" + another-json: "npm:^0.2.0" + bs58: "npm:^6.0.0" + content-type: "npm:^1.0.4" + jwt-decode: "npm:^4.0.0" + loglevel: "npm:^1.9.2" + matrix-events-sdk: "npm:0.0.1" + matrix-widget-api: "npm:^1.10.0" + oidc-client-ts: "npm:^3.0.1" + p-retry: "npm:7" + sdp-transform: "npm:^2.14.1" + unhomoglyph: "npm:^1.0.6" + uuid: "npm:13" + checksum: 10/b0b64e496ab32a5a7d659a6ff7effa66aaa4ced71c8974ba1ffd1a03d33f181c949e7ad5913b64c298434abbe061e1dbd40369cb381fa1be9d3677aa90b15db3 + languageName: node + linkType: hard + +"matrix-widget-api@npm:^1.10.0": + version: 1.15.0 + resolution: "matrix-widget-api@npm:1.15.0" + dependencies: + "@types/events": "npm:^3.0.0" + events: "npm:^3.2.0" + checksum: 10/f9f63c629394ec31346ea1ff37bcb40624dc33503a38147ebea013dba6d032aab1c6b6f857f38bda7641443a340f489bfeebfe6c9bf213708a2f8766cbb54b53 + languageName: node + linkType: hard + "md5.js@npm:^1.3.4": version: 1.3.5 resolution: "md5.js@npm:1.3.5" @@ -28907,6 +28999,15 @@ __metadata: languageName: node linkType: hard +"oidc-client-ts@npm:^3.0.1": + version: 3.4.0 + resolution: "oidc-client-ts@npm:3.4.0" + dependencies: + jwt-decode: "npm:^4.0.0" + checksum: 10/d6e4dd5e2f4cd7da0ede9cf1bca131ea9811f030706d5322409b476047746e3c8f2d117804eb488187cc3c2253f986f8a1e053ae2c943b525c6e4d2a1c1b8094 + languageName: node + linkType: hard + "on-exit-leak-free@npm:^0.2.0": version: 0.2.0 resolution: "on-exit-leak-free@npm:0.2.0" @@ -29237,6 +29338,15 @@ __metadata: languageName: node linkType: hard +"p-retry@npm:7": + version: 7.1.0 + resolution: "p-retry@npm:7.1.0" + dependencies: + is-network-error: "npm:^1.1.0" + checksum: 10/25d0c47fb7d8989efa422e3cb44bcd4006323bdee89ae75995d8617eefe732e2524c40cf9b3c4ee703d0af88a1e88b44865d4548727b1c29d6b74617d5a8f571 + languageName: node + linkType: hard + "p-retry@npm:^4, p-retry@npm:^4.0.0": version: 4.6.2 resolution: "p-retry@npm:4.6.2" @@ -33003,7 +33113,7 @@ __metadata: languageName: node linkType: hard -"sdp-transform@npm:^2.15.0": +"sdp-transform@npm:^2.14.1, sdp-transform@npm:^2.15.0": version: 2.15.0 resolution: "sdp-transform@npm:2.15.0" bin: @@ -36080,6 +36190,13 @@ __metadata: languageName: node linkType: hard +"unhomoglyph@npm:^1.0.6": + version: 1.0.6 + resolution: "unhomoglyph@npm:1.0.6" + checksum: 10/96442934bd16b62e6261fbd9381d9baaa910e2720006ef6b6a270e810b3c867226436353f024e85e5d5270acf9cf9e51d2f7982a4b7c12392a5143bd5d798640 + languageName: node + linkType: hard + "unicode-canonical-property-names-ecmascript@npm:^2.0.0": version: 2.0.0 resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.0" @@ -36525,6 +36642,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:13": + version: 13.0.0 + resolution: "uuid@npm:13.0.0" + bin: + uuid: dist-node/bin/uuid + checksum: 10/2742b24d1e00257e60612572e4d28679423469998cafbaf1fe9f1482e3edf9c40754b31bfdb3d08d71b29239f227a304588f75210b3b48f2609f0673f1feccef + languageName: node + linkType: hard + "uuid@npm:8.0.0": version: 8.0.0 resolution: "uuid@npm:8.0.0"